diff --git a/surfsense_backend/alembic/versions/74_add_composio_connector_enums.py b/surfsense_backend/alembic/versions/74_add_composio_connector_enums.py
deleted file mode 100644
index 454b60754..000000000
--- a/surfsense_backend/alembic/versions/74_add_composio_connector_enums.py
+++ /dev/null
@@ -1,81 +0,0 @@
-"""Add COMPOSIO_CONNECTOR to SearchSourceConnectorType and DocumentType enums
-
-Revision ID: 74
-Revises: 73
-Create Date: 2026-01-21
-
-This migration adds the COMPOSIO_CONNECTOR enum value to both:
-- searchsourceconnectortype (for connector type tracking)
-- documenttype (for document type tracking)
-
-Composio is a managed OAuth integration service that allows connecting
-to various third-party services (Google Drive, Gmail, Calendar, etc.)
-without requiring separate OAuth app verification.
-"""
-
-from collections.abc import Sequence
-
-from alembic import op
-
-# revision identifiers, used by Alembic.
-revision: str = "74"
-down_revision: str | None = "73"
-branch_labels: str | Sequence[str] | None = None
-depends_on: str | Sequence[str] | None = None
-
-# Define the ENUM type names and the new value
-CONNECTOR_ENUM = "searchsourceconnectortype"
-CONNECTOR_NEW_VALUE = "COMPOSIO_CONNECTOR"
-DOCUMENT_ENUM = "documenttype"
-DOCUMENT_NEW_VALUE = "COMPOSIO_CONNECTOR"
-
-
-def upgrade() -> None:
- """Upgrade schema - add COMPOSIO_CONNECTOR to connector and document enums safely."""
- # Add COMPOSIO_CONNECTOR to searchsourceconnectortype only if not exists
- op.execute(
- f"""
- DO $$
- BEGIN
- IF NOT EXISTS (
- SELECT 1 FROM pg_enum
- WHERE enumlabel = '{CONNECTOR_NEW_VALUE}'
- AND enumtypid = (SELECT oid FROM pg_type WHERE typname = '{CONNECTOR_ENUM}')
- ) THEN
- ALTER TYPE {CONNECTOR_ENUM} ADD VALUE '{CONNECTOR_NEW_VALUE}';
- END IF;
- END$$;
- """
- )
-
- # Add COMPOSIO_CONNECTOR to documenttype only if not exists
- op.execute(
- f"""
- DO $$
- BEGIN
- IF NOT EXISTS (
- SELECT 1 FROM pg_enum
- WHERE enumlabel = '{DOCUMENT_NEW_VALUE}'
- AND enumtypid = (SELECT oid FROM pg_type WHERE typname = '{DOCUMENT_ENUM}')
- ) THEN
- ALTER TYPE {DOCUMENT_ENUM} ADD VALUE '{DOCUMENT_NEW_VALUE}';
- END IF;
- END$$;
- """
- )
-
-
-def downgrade() -> None:
- """Downgrade schema - remove COMPOSIO_CONNECTOR from connector and document enums.
-
- Note: PostgreSQL does not support removing enum values directly.
- To properly downgrade, you would need to:
- 1. Delete any rows using the COMPOSIO_CONNECTOR value
- 2. Create new enums without COMPOSIO_CONNECTOR
- 3. Alter the columns to use the new enums
- 4. Drop the old enums
-
- This is left as a no-op since removing enum values is complex
- and typically not needed in practice.
- """
- pass
diff --git a/surfsense_backend/alembic/versions/74_no_op.py b/surfsense_backend/alembic/versions/74_no_op.py
new file mode 100644
index 000000000..a5ee99b29
--- /dev/null
+++ b/surfsense_backend/alembic/versions/74_no_op.py
@@ -0,0 +1,29 @@
+"""No-op migration for Composio support
+
+Revision ID: 74
+Revises: 73
+Create Date: 2026-01-21
+
+NOTE: This migration is a no-op since Composio is not supported yet.
+"""
+
+from collections.abc import Sequence
+
+# revision identifiers, used by Alembic.
+revision: str = "74"
+down_revision: str | None = "73"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """No-op upgrade for Composio support."""
+ pass
+
+
+def downgrade() -> None:
+ """No-op downgrade for Composio support.
+
+ Note: PostgreSQL does not support removing enum values directly.
+ """
+ pass
diff --git a/surfsense_backend/alembic/versions/75_add_chat_session_state_table.py b/surfsense_backend/alembic/versions/75_add_chat_session_state_table.py
new file mode 100644
index 000000000..46bf7b9b9
--- /dev/null
+++ b/surfsense_backend/alembic/versions/75_add_chat_session_state_table.py
@@ -0,0 +1,75 @@
+"""Add chat_session_state table for live collaboration
+
+Revision ID: 75
+Revises: 74
+
+Creates chat_session_state table to track AI responding state per thread.
+Enables real-time sync via Electric SQL for shared chat collaboration.
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+
+revision: str = "75"
+down_revision: str | None = "74"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Create chat_session_state table with Electric SQL replication."""
+ op.execute(
+ """
+ CREATE TABLE IF NOT EXISTS chat_session_state (
+ id SERIAL PRIMARY KEY,
+ thread_id INTEGER NOT NULL REFERENCES new_chat_threads(id) ON DELETE CASCADE,
+ ai_responding_to_user_id UUID REFERENCES "user"(id) ON DELETE SET NULL,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (thread_id)
+ )
+ """
+ )
+
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS idx_chat_session_state_thread_id ON chat_session_state(thread_id)"
+ )
+
+ op.execute("ALTER TABLE chat_session_state REPLICA IDENTITY FULL;")
+
+ op.execute(
+ """
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_publication_tables
+ WHERE pubname = 'electric_publication_default'
+ AND tablename = 'chat_session_state'
+ ) THEN
+ ALTER PUBLICATION electric_publication_default ADD TABLE chat_session_state;
+ END IF;
+ END
+ $$;
+ """
+ )
+
+
+def downgrade() -> None:
+ """Drop chat_session_state table and remove from Electric SQL replication."""
+ op.execute(
+ """
+ DO $$
+ BEGIN
+ IF EXISTS (
+ SELECT 1 FROM pg_publication_tables
+ WHERE pubname = 'electric_publication_default'
+ AND tablename = 'chat_session_state'
+ ) THEN
+ ALTER PUBLICATION electric_publication_default DROP TABLE chat_session_state;
+ END IF;
+ END
+ $$;
+ """
+ )
+
+ op.execute("DROP TABLE IF EXISTS chat_session_state;")
diff --git a/surfsense_backend/alembic/versions/75_add_obsidian_connector.py b/surfsense_backend/alembic/versions/75_add_obsidian_connector.py
deleted file mode 100644
index d05fce7f1..000000000
--- a/surfsense_backend/alembic/versions/75_add_obsidian_connector.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""Add Obsidian connector enums
-
-Revision ID: 75
-Revises: 74
-Create Date: 2026-01-21
-
-"""
-
-from collections.abc import Sequence
-
-from alembic import op
-
-# revision identifiers, used by Alembic.
-revision: str = "75"
-down_revision: str | None = "74"
-branch_labels: str | Sequence[str] | None = None
-depends_on: str | Sequence[str] | None = None
-
-
-def upgrade() -> None:
- # Add OBSIDIAN_CONNECTOR to documenttype enum
- op.execute("ALTER TYPE documenttype ADD VALUE IF NOT EXISTS 'OBSIDIAN_CONNECTOR'")
-
- # Add OBSIDIAN_CONNECTOR to searchsourceconnectortype enum
- op.execute(
- "ALTER TYPE searchsourceconnectortype ADD VALUE IF NOT EXISTS 'OBSIDIAN_CONNECTOR'"
- )
-
-
-def downgrade() -> None:
- # Note: PostgreSQL doesn't support removing enum values directly.
- # The values will remain in the enum type but won't be used.
- pass
diff --git a/surfsense_backend/alembic/versions/76_add_live_collaboration_tables_electric_replication.py b/surfsense_backend/alembic/versions/76_add_live_collaboration_tables_electric_replication.py
new file mode 100644
index 000000000..68bca4fc1
--- /dev/null
+++ b/surfsense_backend/alembic/versions/76_add_live_collaboration_tables_electric_replication.py
@@ -0,0 +1,99 @@
+"""Add live collaboration tables to Electric SQL publication
+
+Revision ID: 76
+Revises: 75
+
+Enables real-time sync for live collaboration features:
+- new_chat_messages: Live message sync between users
+- chat_comments: Live comment updates
+
+Note: User/member info is fetched via API (membersAtom) for client-side joins,
+not via Electric SQL, to keep where clauses optimized and reduce complexity.
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+
+revision: str = "76"
+down_revision: str | None = "75"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Add live collaboration tables to Electric SQL replication."""
+ # Set REPLICA IDENTITY FULL for Electric SQL sync
+ op.execute("ALTER TABLE new_chat_messages REPLICA IDENTITY FULL;")
+ op.execute("ALTER TABLE chat_comments REPLICA IDENTITY FULL;")
+
+ # Add new_chat_messages to Electric publication
+ op.execute(
+ """
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_publication_tables
+ WHERE pubname = 'electric_publication_default'
+ AND tablename = 'new_chat_messages'
+ ) THEN
+ ALTER PUBLICATION electric_publication_default ADD TABLE new_chat_messages;
+ END IF;
+ END
+ $$;
+ """
+ )
+
+ # Add chat_comments to Electric publication
+ op.execute(
+ """
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_publication_tables
+ WHERE pubname = 'electric_publication_default'
+ AND tablename = 'chat_comments'
+ ) THEN
+ ALTER PUBLICATION electric_publication_default ADD TABLE chat_comments;
+ END IF;
+ END
+ $$;
+ """
+ )
+
+
+def downgrade() -> None:
+ """Remove live collaboration tables from Electric SQL replication."""
+ op.execute(
+ """
+ DO $$
+ BEGIN
+ IF EXISTS (
+ SELECT 1 FROM pg_publication_tables
+ WHERE pubname = 'electric_publication_default'
+ AND tablename = 'new_chat_messages'
+ ) THEN
+ ALTER PUBLICATION electric_publication_default DROP TABLE new_chat_messages;
+ END IF;
+ END
+ $$;
+ """
+ )
+
+ op.execute(
+ """
+ DO $$
+ BEGIN
+ IF EXISTS (
+ SELECT 1 FROM pg_publication_tables
+ WHERE pubname = 'electric_publication_default'
+ AND tablename = 'chat_comments'
+ ) THEN
+ ALTER PUBLICATION electric_publication_default DROP TABLE chat_comments;
+ END IF;
+ END
+ $$;
+ """
+ )
+
+ # Note: Not reverting REPLICA IDENTITY as it doesn't harm normal operations
diff --git a/surfsense_backend/alembic/versions/77_add_thread_id_to_chat_comments.py b/surfsense_backend/alembic/versions/77_add_thread_id_to_chat_comments.py
new file mode 100644
index 000000000..86886eacf
--- /dev/null
+++ b/surfsense_backend/alembic/versions/77_add_thread_id_to_chat_comments.py
@@ -0,0 +1,70 @@
+"""Add thread_id to chat_comments for denormalized Electric subscriptions
+
+This denormalization allows a single Electric SQL subscription per thread
+instead of one per message, significantly reducing connection overhead.
+
+Revision ID: 77
+Revises: 76
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+
+revision: str = "77"
+down_revision: str | None = "76"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Add thread_id column to chat_comments and backfill from messages."""
+ # Add the column (nullable initially for backfill)
+ op.execute(
+ """
+ ALTER TABLE chat_comments
+ ADD COLUMN IF NOT EXISTS thread_id INTEGER;
+ """
+ )
+
+ # Backfill thread_id from the related message
+ op.execute(
+ """
+ UPDATE chat_comments c
+ SET thread_id = m.thread_id
+ FROM new_chat_messages m
+ WHERE c.message_id = m.id
+ AND c.thread_id IS NULL;
+ """
+ )
+
+ # Make it NOT NULL after backfill
+ op.execute(
+ """
+ ALTER TABLE chat_comments
+ ALTER COLUMN thread_id SET NOT NULL;
+ """
+ )
+
+ # Add FK constraint
+ op.execute(
+ """
+ ALTER TABLE chat_comments
+ ADD CONSTRAINT fk_chat_comments_thread_id
+ FOREIGN KEY (thread_id) REFERENCES new_chat_threads(id) ON DELETE CASCADE;
+ """
+ )
+
+ # Add index for efficient Electric subscriptions by thread
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS idx_chat_comments_thread_id ON chat_comments(thread_id)"
+ )
+
+
+def downgrade() -> None:
+ """Remove thread_id column from chat_comments."""
+ op.execute("DROP INDEX IF EXISTS idx_chat_comments_thread_id")
+ op.execute(
+ "ALTER TABLE chat_comments DROP CONSTRAINT IF EXISTS fk_chat_comments_thread_id"
+ )
+ op.execute("ALTER TABLE chat_comments DROP COLUMN IF EXISTS thread_id")
diff --git a/surfsense_backend/app/connectors/composio_connector.py b/surfsense_backend/app/connectors/composio_connector.py
index e9ee4cc58..fdf57d8ea 100644
--- a/surfsense_backend/app/connectors/composio_connector.py
+++ b/surfsense_backend/app/connectors/composio_connector.py
@@ -268,7 +268,9 @@ class ComposioConnector:
from_email = header_dict.get("from", "Unknown Sender")
to_email = header_dict.get("to", "Unknown Recipient")
# Composio provides messageTimestamp directly
- date_str = message.get("messageTimestamp", "") or header_dict.get("date", "Unknown Date")
+ date_str = message.get("messageTimestamp", "") or header_dict.get(
+ "date", "Unknown Date"
+ )
# Build markdown content
markdown_content = f"# {subject}\n\n"
diff --git a/surfsense_backend/app/connectors/github_connector.py b/surfsense_backend/app/connectors/github_connector.py
index 6f04ccdba..9d4b98c4b 100644
--- a/surfsense_backend/app/connectors/github_connector.py
+++ b/surfsense_backend/app/connectors/github_connector.py
@@ -58,7 +58,9 @@ class GitHubConnector:
if self.token:
logger.info("GitHub connector initialized with authentication token.")
else:
- logger.info("GitHub connector initialized without token (public repos only).")
+ logger.info(
+ "GitHub connector initialized without token (public repos only)."
+ )
def ingest_repository(
self,
@@ -95,17 +97,27 @@ class GitHubConnector:
cmd = [
"gitingest",
repo_url,
- "--output", output_path,
- "--max-size", str(max_file_size),
+ "--output",
+ output_path,
+ "--max-size",
+ str(max_file_size),
# Common exclude patterns
- "-e", "node_modules/*",
- "-e", "vendor/*",
- "-e", ".git/*",
- "-e", "__pycache__/*",
- "-e", "dist/*",
- "-e", "build/*",
- "-e", "*.lock",
- "-e", "package-lock.json",
+ "-e",
+ "node_modules/*",
+ "-e",
+ "vendor/*",
+ "-e",
+ ".git/*",
+ "-e",
+ "__pycache__/*",
+ "-e",
+ "dist/*",
+ "-e",
+ "build/*",
+ "-e",
+ "*.lock",
+ "-e",
+ "package-lock.json",
]
# Add branch if specified
@@ -147,7 +159,9 @@ class GitHubConnector:
os.unlink(output_path)
if not full_content or not full_content.strip():
- logger.warning(f"No content retrieved from repository: {repo_full_name}")
+ logger.warning(
+ f"No content retrieved from repository: {repo_full_name}"
+ )
return None
# Parse the gitingest output
@@ -171,11 +185,11 @@ class GitHubConnector:
logger.error(f"gitingest timed out for repository: {repo_full_name}")
return None
except FileNotFoundError:
- logger.error(
- "gitingest CLI not found. Falling back to Python library."
- )
+ logger.error("gitingest CLI not found. Falling back to Python library.")
# Fall back to Python library
- return self._ingest_with_python_library(repo_full_name, branch, max_file_size)
+ return self._ingest_with_python_library(
+ repo_full_name, branch, max_file_size
+ )
except Exception as e:
logger.error(f"Failed to ingest repository {repo_full_name}: {e}")
return None
diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py
index 992bab14c..d5fad60f3 100644
--- a/surfsense_backend/app/db.py
+++ b/surfsense_backend/app/db.py
@@ -84,7 +84,9 @@ class SearchSourceConnectorType(str, Enum):
CIRCLEBACK_CONNECTOR = "CIRCLEBACK_CONNECTOR"
OBSIDIAN_CONNECTOR = "OBSIDIAN_CONNECTOR" # Self-hosted only - Local Obsidian vault indexing
MCP_CONNECTOR = "MCP_CONNECTOR" # Model Context Protocol - User-defined API tools
- COMPOSIO_CONNECTOR = "COMPOSIO_CONNECTOR" # Generic Composio integration (Google, Slack, etc.)
+ COMPOSIO_CONNECTOR = (
+ "COMPOSIO_CONNECTOR" # Generic Composio integration (Google, Slack, etc.)
+ )
class LiteLLMProvider(str, Enum):
@@ -417,6 +419,13 @@ class ChatComment(BaseModel, TimestampMixin):
nullable=False,
index=True,
)
+ # Denormalized thread_id for efficient Electric SQL subscriptions (one per thread)
+ thread_id = Column(
+ Integer,
+ ForeignKey("new_chat_threads.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
parent_id = Column(
Integer,
ForeignKey("chat_comments.id", ondelete="CASCADE"),
@@ -440,6 +449,7 @@ class ChatComment(BaseModel, TimestampMixin):
# Relationships
message = relationship("NewChatMessage", back_populates="comments")
+ thread = relationship("NewChatThread")
author = relationship("User")
parent = relationship(
"ChatComment", remote_side="ChatComment.id", backref="replies"
@@ -476,6 +486,38 @@ class ChatCommentMention(BaseModel, TimestampMixin):
mentioned_user = relationship("User")
+class ChatSessionState(BaseModel):
+ """
+ Tracks real-time session state for shared chat collaboration.
+ One record per thread, synced via Electric SQL.
+ """
+
+ __tablename__ = "chat_session_state"
+
+ thread_id = Column(
+ Integer,
+ ForeignKey("new_chat_threads.id", ondelete="CASCADE"),
+ nullable=False,
+ unique=True,
+ index=True,
+ )
+ ai_responding_to_user_id = Column(
+ UUID(as_uuid=True),
+ ForeignKey("user.id", ondelete="SET NULL"),
+ nullable=True,
+ index=True,
+ )
+ updated_at = Column(
+ TIMESTAMP(timezone=True),
+ nullable=False,
+ default=lambda: datetime.now(UTC),
+ onupdate=lambda: datetime.now(UTC),
+ )
+
+ thread = relationship("NewChatThread")
+ ai_responding_to_user = relationship("User")
+
+
class MemoryCategory(str, Enum):
"""Categories for user memories."""
diff --git a/surfsense_backend/app/routes/composio_routes.py b/surfsense_backend/app/routes/composio_routes.py
index cba8b0ae8..eecbaf598 100644
--- a/surfsense_backend/app/routes/composio_routes.py
+++ b/surfsense_backend/app/routes/composio_routes.py
@@ -84,7 +84,9 @@ async def list_composio_toolkits(user: User = Depends(current_active_user)):
@router.get("/auth/composio/connector/add")
async def initiate_composio_auth(
space_id: int,
- toolkit_id: str = Query(..., description="Composio toolkit ID (e.g., 'googledrive', 'gmail')"),
+ toolkit_id: str = Query(
+ ..., description="Composio toolkit ID (e.g., 'googledrive', 'gmail')"
+ ),
user: User = Depends(current_active_user),
):
"""
@@ -165,7 +167,9 @@ async def initiate_composio_auth(
@router.get("/auth/composio/connector/callback")
async def composio_callback(
state: str | None = None,
- connectedAccountId: str | None = None, # Composio sends camelCase
+ composio_connected_account_id: str | None = Query(
+ None, alias="connectedAccountId"
+ ), # Composio sends camelCase
connected_account_id: str | None = None, # Fallback snake_case
error: str | None = None,
session: AsyncSession = Depends(get_async_session),
@@ -232,15 +236,18 @@ async def composio_callback(
)
# Initialize Composio service
- service = ComposioService()
- entity_id = f"surfsense_{user_id}"
-
+ ComposioService()
+
# Use camelCase param if provided (Composio's format), fallback to snake_case
- final_connected_account_id = connectedAccountId or connected_account_id
-
+ final_connected_account_id = (
+ composio_connected_account_id or connected_account_id
+ )
+
# DEBUG: Log all query parameters received
- logger.info(f"DEBUG: Callback received - connectedAccountId: {connectedAccountId}, connected_account_id: {connected_account_id}, using: {final_connected_account_id}")
-
+ logger.info(
+ f"DEBUG: Callback received - connectedAccountId: {composio_connected_account_id}, connected_account_id: {connected_account_id}, using: {final_connected_account_id}"
+ )
+
# If we still don't have a connected_account_id, warn but continue
# (the connector will be created but indexing won't work until updated)
if not final_connected_account_id:
@@ -249,7 +256,9 @@ async def composio_callback(
"The connector will be created but indexing may not work."
)
else:
- logger.info(f"Successfully got connected_account_id: {final_connected_account_id}")
+ logger.info(
+ f"Successfully got connected_account_id: {final_connected_account_id}"
+ )
# Build connector config
connector_config = {
diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py
index 4b8600fab..25c53b69e 100644
--- a/surfsense_backend/app/routes/new_chat_routes.py
+++ b/surfsense_backend/app/routes/new_chat_routes.py
@@ -990,7 +990,7 @@ async def handle_new_chat(
search_space_id=request.search_space_id,
chat_id=request.chat_id,
session=session,
- user_id=str(user.id), # Pass user ID for memory tools
+ user_id=str(user.id), # Pass user ID for memory tools and session state
llm_config_id=llm_config_id,
attachments=request.attachments,
mentioned_document_ids=request.mentioned_document_ids,
diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py
index deee748d8..6bc945643 100644
--- a/surfsense_backend/app/routes/notifications_routes.py
+++ b/surfsense_backend/app/routes/notifications_routes.py
@@ -1,12 +1,15 @@
"""
Notifications API routes.
-These endpoints allow marking notifications as read.
-Electric SQL automatically syncs the changes to all connected clients.
+These endpoints allow marking notifications as read and fetching older notifications.
+Electric SQL automatically syncs the changes to all connected clients for recent items.
+For older items (beyond the sync window), use the list endpoint.
"""
-from fastapi import APIRouter, Depends, HTTPException, status
+from datetime import UTC, datetime, timedelta
+
+from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
-from sqlalchemy import select, update
+from sqlalchemy import desc, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Notification, User, get_async_session
@@ -14,6 +17,36 @@ from app.users import current_active_user
router = APIRouter(prefix="/notifications", tags=["notifications"])
+# Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts
+SYNC_WINDOW_DAYS = 14
+
+
+class NotificationResponse(BaseModel):
+ """Response model for a single notification."""
+
+ id: int
+ user_id: str
+ search_space_id: int | None
+ type: str
+ title: str
+ message: str
+ read: bool
+ metadata: dict
+ created_at: str
+ updated_at: str | None
+
+ class Config:
+ from_attributes = True
+
+
+class NotificationListResponse(BaseModel):
+ """Response for listing notifications with pagination."""
+
+ items: list[NotificationResponse]
+ total: int
+ has_more: bool
+ next_offset: int | None
+
class MarkReadResponse(BaseModel):
"""Response for mark as read operations."""
@@ -30,6 +63,169 @@ class MarkAllReadResponse(BaseModel):
updated_count: int
+class UnreadCountResponse(BaseModel):
+ """Response for unread count with split between recent and older items."""
+
+ total_unread: int
+ recent_unread: int # Within SYNC_WINDOW_DAYS
+
+
+@router.get("/unread-count", response_model=UnreadCountResponse)
+async def get_unread_count(
+ search_space_id: int | None = Query(None, description="Filter by search space ID"),
+ user: User = Depends(current_active_user),
+ session: AsyncSession = Depends(get_async_session),
+) -> UnreadCountResponse:
+ """
+ Get the total unread notification count for the current user.
+
+ Returns both:
+ - total_unread: All unread notifications (for accurate badge count)
+ - recent_unread: Unread notifications within the sync window (last 14 days)
+
+ This allows the frontend to calculate:
+ - older_unread = total_unread - recent_unread (static until reconciliation)
+ - Display count = older_unread + live_recent_count (from Electric SQL)
+ """
+ # Calculate cutoff date for sync window
+ cutoff_date = datetime.now(UTC) - timedelta(days=SYNC_WINDOW_DAYS)
+
+ # Base filter for user's unread notifications
+ base_filter = [
+ Notification.user_id == user.id,
+ Notification.read == False, # noqa: E712
+ ]
+
+ # Add search space filter if provided (include null for global notifications)
+ if search_space_id is not None:
+ base_filter.append(
+ (Notification.search_space_id == search_space_id)
+ | (Notification.search_space_id.is_(None))
+ )
+
+ # Total unread count (all time)
+ total_query = select(func.count(Notification.id)).where(*base_filter)
+ total_result = await session.execute(total_query)
+ total_unread = total_result.scalar() or 0
+
+ # Recent unread count (within sync window)
+ recent_query = select(func.count(Notification.id)).where(
+ *base_filter,
+ Notification.created_at > cutoff_date,
+ )
+ recent_result = await session.execute(recent_query)
+ recent_unread = recent_result.scalar() or 0
+
+ return UnreadCountResponse(
+ total_unread=total_unread,
+ recent_unread=recent_unread,
+ )
+
+
+@router.get("", response_model=NotificationListResponse)
+async def list_notifications(
+ search_space_id: int | None = Query(None, description="Filter by search space ID"),
+ type_filter: str | None = Query(
+ None, alias="type", description="Filter by notification type"
+ ),
+ before_date: str | None = Query(
+ None, description="Get notifications before this ISO date (for pagination)"
+ ),
+ limit: int = Query(50, ge=1, le=100, description="Number of items to return"),
+ offset: int = Query(0, ge=0, description="Number of items to skip"),
+ user: User = Depends(current_active_user),
+ session: AsyncSession = Depends(get_async_session),
+) -> NotificationListResponse:
+ """
+ List notifications for the current user with pagination.
+
+ This endpoint is used as a fallback for older notifications that are
+ outside the Electric SQL sync window (2 weeks).
+
+ Use `before_date` to paginate through older notifications efficiently.
+ """
+ # Build base query
+ query = select(Notification).where(Notification.user_id == user.id)
+ count_query = select(func.count(Notification.id)).where(
+ Notification.user_id == user.id
+ )
+
+ # Filter by search space (include null search_space_id for global notifications)
+ if search_space_id is not None:
+ query = query.where(
+ (Notification.search_space_id == search_space_id)
+ | (Notification.search_space_id.is_(None))
+ )
+ count_query = count_query.where(
+ (Notification.search_space_id == search_space_id)
+ | (Notification.search_space_id.is_(None))
+ )
+
+ # Filter by type
+ if type_filter:
+ query = query.where(Notification.type == type_filter)
+ count_query = count_query.where(Notification.type == type_filter)
+
+ # Filter by date (for efficient pagination of older items)
+ if before_date:
+ try:
+ before_datetime = datetime.fromisoformat(before_date.replace("Z", "+00:00"))
+ query = query.where(Notification.created_at < before_datetime)
+ count_query = count_query.where(Notification.created_at < before_datetime)
+ except ValueError:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid date format. Use ISO format (e.g., 2024-01-15T00:00:00Z)",
+ ) from None
+
+ # Get total count
+ total_result = await session.execute(count_query)
+ total = total_result.scalar() or 0
+
+ # Apply ordering and pagination
+ query = (
+ query.order_by(desc(Notification.created_at)).offset(offset).limit(limit + 1)
+ )
+
+ # Execute query
+ result = await session.execute(query)
+ notifications = result.scalars().all()
+
+ # Check if there are more items
+ has_more = len(notifications) > limit
+ if has_more:
+ notifications = notifications[:limit]
+
+ # Convert to response format
+ items = []
+ for notification in notifications:
+ items.append(
+ NotificationResponse(
+ id=notification.id,
+ user_id=str(notification.user_id),
+ search_space_id=notification.search_space_id,
+ type=notification.type,
+ title=notification.title,
+ message=notification.message,
+ read=notification.read,
+ metadata=notification.notification_metadata or {},
+ created_at=notification.created_at.isoformat()
+ if notification.created_at
+ else "",
+ updated_at=notification.updated_at.isoformat()
+ if notification.updated_at
+ else None,
+ )
+ )
+
+ return NotificationListResponse(
+ items=items,
+ total=total,
+ has_more=has_more,
+ next_offset=offset + limit if has_more else None,
+ )
+
+
@router.patch("/{notification_id}/read", response_model=MarkReadResponse)
async def mark_notification_as_read(
notification_id: int,
diff --git a/surfsense_backend/app/schemas/chat_session_state.py b/surfsense_backend/app/schemas/chat_session_state.py
new file mode 100644
index 000000000..6eca0e26f
--- /dev/null
+++ b/surfsense_backend/app/schemas/chat_session_state.py
@@ -0,0 +1,29 @@
+"""
+Pydantic schemas for chat session state (live collaboration).
+"""
+
+from datetime import datetime
+from uuid import UUID
+
+from pydantic import BaseModel, ConfigDict
+
+
+class RespondingUser(BaseModel):
+ """The user that the AI is currently responding to."""
+
+ id: UUID
+ display_name: str | None = None
+ email: str
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class ChatSessionStateResponse(BaseModel):
+ """Current session state for a chat thread."""
+
+ id: int
+ thread_id: int
+ responding_to: RespondingUser | None = None
+ updated_at: datetime
+
+ model_config = ConfigDict(from_attributes=True)
diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py
index 6f81c0158..dc3b51238 100644
--- a/surfsense_backend/app/services/chat_comments_service.py
+++ b/surfsense_backend/app/services/chat_comments_service.py
@@ -281,8 +281,10 @@ async def create_comment(
detail="You don't have permission to create comments in this search space",
)
+ thread = message.thread
comment = ChatComment(
message_id=message_id,
+ thread_id=thread.id, # Denormalized for efficient Electric subscriptions
author_id=user.id,
content=content,
)
@@ -299,7 +301,6 @@ async def create_comment(
user_names = await get_user_names_for_mentions(session, set(mentions_map.keys()))
# Create notifications for mentioned users (excluding author)
- thread = message.thread
author_name = user.display_name or user.email
content_preview = render_mentions(content, user_names)
for mentioned_user_id, mention_id in mentions_map.items():
@@ -393,8 +394,10 @@ async def create_reply(
detail="You don't have permission to create comments in this search space",
)
+ thread = parent_comment.message.thread
reply = ChatComment(
message_id=parent_comment.message_id,
+ thread_id=thread.id, # Denormalized for efficient Electric subscriptions
parent_id=comment_id,
author_id=user.id,
content=content,
@@ -412,7 +415,6 @@ async def create_reply(
user_names = await get_user_names_for_mentions(session, set(mentions_map.keys()))
# Create notifications for mentioned users (excluding author)
- thread = parent_comment.message.thread
author_name = user.display_name or user.email
content_preview = render_mentions(content, user_names)
for mentioned_user_id, mention_id in mentions_map.items():
diff --git a/surfsense_backend/app/services/chat_session_state_service.py b/surfsense_backend/app/services/chat_session_state_service.py
new file mode 100644
index 000000000..d82fff3a7
--- /dev/null
+++ b/surfsense_backend/app/services/chat_session_state_service.py
@@ -0,0 +1,65 @@
+"""
+Service layer for chat session state (live collaboration).
+"""
+
+from datetime import UTC, datetime
+from uuid import UUID
+
+from sqlalchemy import select
+from sqlalchemy.dialects.postgresql import insert
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.db import ChatSessionState
+
+
+async def get_session_state(
+ session: AsyncSession,
+ thread_id: int,
+) -> ChatSessionState | None:
+ """Get the current session state for a thread."""
+ result = await session.execute(
+ select(ChatSessionState)
+ .options(selectinload(ChatSessionState.ai_responding_to_user))
+ .filter(ChatSessionState.thread_id == thread_id)
+ )
+ return result.scalar_one_or_none()
+
+
+async def set_ai_responding(
+ session: AsyncSession,
+ thread_id: int,
+ user_id: UUID,
+) -> ChatSessionState:
+ """Mark AI as responding to a specific user. Uses upsert for atomicity."""
+ now = datetime.now(UTC)
+ upsert_query = insert(ChatSessionState).values(
+ thread_id=thread_id,
+ ai_responding_to_user_id=user_id,
+ updated_at=now,
+ )
+ upsert_query = upsert_query.on_conflict_do_update(
+ index_elements=["thread_id"],
+ set_={
+ "ai_responding_to_user_id": user_id,
+ "updated_at": now,
+ },
+ )
+ await session.execute(upsert_query)
+ await session.commit()
+
+ return await get_session_state(session, thread_id)
+
+
+async def clear_ai_responding(
+ session: AsyncSession,
+ thread_id: int,
+) -> ChatSessionState | None:
+ """Clear AI responding state when response is complete."""
+ state = await get_session_state(session, thread_id)
+ if state:
+ state.ai_responding_to_user_id = None
+ state.updated_at = datetime.now(UTC)
+ await session.commit()
+ await session.refresh(state)
+ return state
diff --git a/surfsense_backend/app/services/composio_service.py b/surfsense_backend/app/services/composio_service.py
index 4b6a32b03..6046ea2d8 100644
--- a/surfsense_backend/app/services/composio_service.py
+++ b/surfsense_backend/app/services/composio_service.py
@@ -97,7 +97,7 @@ class ComposioService:
config_toolkit = getattr(auth_config, "toolkit", None)
if config_toolkit is None:
continue
-
+
# Extract toolkit name/slug from the object
toolkit_name = None
if isinstance(config_toolkit, str):
@@ -108,18 +108,22 @@ class ComposioService:
toolkit_name = config_toolkit.name
elif hasattr(config_toolkit, "id"):
toolkit_name = config_toolkit.id
-
+
# Compare case-insensitively
if toolkit_name and toolkit_name.lower() == toolkit_id.lower():
- logger.info(f"Found auth config {auth_config.id} for toolkit {toolkit_id}")
+ logger.info(
+ f"Found auth config {auth_config.id} for toolkit {toolkit_id}"
+ )
return auth_config.id
-
+
# Log available auth configs for debugging
- logger.warning(f"No auth config found for toolkit '{toolkit_id}'. Available auth configs:")
+ logger.warning(
+ f"No auth config found for toolkit '{toolkit_id}'. Available auth configs:"
+ )
for auth_config in auth_configs.items:
config_toolkit = getattr(auth_config, "toolkit", None)
logger.warning(f" - {auth_config.id}: toolkit={config_toolkit}")
-
+
return None
except Exception as e:
logger.error(f"Failed to list auth configs: {e!s}")
@@ -148,7 +152,7 @@ class ComposioService:
try:
# First, get the auth_config_id for this toolkit
auth_config_id = self._get_auth_config_for_toolkit(toolkit_id)
-
+
if not auth_config_id:
raise ValueError(
f"No auth config found for toolkit '{toolkit_id}'. "
@@ -200,7 +204,9 @@ class ComposioService:
"user_id": getattr(account, "user_id", None),
}
except Exception as e:
- logger.error(f"Failed to get connected account {connected_account_id}: {e!s}")
+ logger.error(
+ f"Failed to get connected account {connected_account_id}: {e!s}"
+ )
return None
async def list_all_connections(self) -> list[dict[str, Any]]:
@@ -212,15 +218,17 @@ class ComposioService:
"""
try:
accounts_response = self.client.connected_accounts.list()
-
+
if hasattr(accounts_response, "items"):
accounts = accounts_response.items
elif hasattr(accounts_response, "__iter__"):
accounts = accounts_response
else:
- logger.warning(f"Unexpected accounts response type: {type(accounts_response)}")
+ logger.warning(
+ f"Unexpected accounts response type: {type(accounts_response)}"
+ )
return []
-
+
result = []
for acc in accounts:
toolkit_raw = getattr(acc, "toolkit", None)
@@ -234,14 +242,16 @@ class ComposioService:
toolkit_info = toolkit_raw.name
else:
toolkit_info = str(toolkit_raw)
-
- result.append({
- "id": acc.id,
- "status": getattr(acc, "status", None),
- "toolkit": toolkit_info,
- "user_id": getattr(acc, "user_id", None),
- })
-
+
+ result.append(
+ {
+ "id": acc.id,
+ "status": getattr(acc, "status", None),
+ "toolkit": toolkit_info,
+ "user_id": getattr(acc, "user_id", None),
+ }
+ )
+
logger.info(f"DEBUG: Found {len(result)} TOTAL connections in Composio")
return result
except Exception as e:
@@ -261,16 +271,18 @@ class ComposioService:
try:
logger.info(f"DEBUG: Calling connected_accounts.list(user_id='{user_id}')")
accounts_response = self.client.connected_accounts.list(user_id=user_id)
-
+
# Handle paginated response (may have .items attribute) or direct list
if hasattr(accounts_response, "items"):
accounts = accounts_response.items
elif hasattr(accounts_response, "__iter__"):
accounts = accounts_response
else:
- logger.warning(f"Unexpected accounts response type: {type(accounts_response)}")
+ logger.warning(
+ f"Unexpected accounts response type: {type(accounts_response)}"
+ )
return []
-
+
result = []
for acc in accounts:
# Extract toolkit info - might be string or object
@@ -285,13 +297,15 @@ class ComposioService:
toolkit_info = toolkit_raw.name
else:
toolkit_info = toolkit_raw
-
- result.append({
- "id": acc.id,
- "status": getattr(acc, "status", None),
- "toolkit": toolkit_info,
- })
-
+
+ result.append(
+ {
+ "id": acc.id,
+ "status": getattr(acc, "status", None),
+ "toolkit": toolkit_info,
+ }
+ )
+
logger.info(f"Found {len(result)} connections for user {user_id}: {result}")
return result
except Exception as e:
@@ -383,18 +397,24 @@ class ComposioService:
return [], None, result.get("error", "Unknown error")
data = result.get("data", {})
- logger.info(f"DEBUG: Drive data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}")
-
+ logger.info(
+ f"DEBUG: Drive data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}"
+ )
+
# Handle nested response structure from Composio
files = []
next_token = None
if isinstance(data, dict):
# Try direct access first, then nested
files = data.get("files", []) or data.get("data", {}).get("files", [])
- next_token = data.get("nextPageToken") or data.get("next_page_token") or data.get("data", {}).get("nextPageToken")
+ next_token = (
+ data.get("nextPageToken")
+ or data.get("next_page_token")
+ or data.get("data", {}).get("nextPageToken")
+ )
elif isinstance(data, list):
files = data
-
+
logger.info(f"DEBUG: Extracted {len(files)} drive files")
return files, next_token, None
@@ -475,16 +495,22 @@ class ComposioService:
return [], result.get("error", "Unknown error")
data = result.get("data", {})
- logger.info(f"DEBUG: Gmail data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}")
+ logger.info(
+ f"DEBUG: Gmail data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}"
+ )
logger.info(f"DEBUG: Gmail full data: {data}")
-
+
# Try different possible response structures
messages = []
if isinstance(data, dict):
- messages = data.get("messages", []) or data.get("data", {}).get("messages", []) or data.get("emails", [])
+ messages = (
+ data.get("messages", [])
+ or data.get("data", {}).get("messages", [])
+ or data.get("emails", [])
+ )
elif isinstance(data, list):
messages = data
-
+
logger.info(f"DEBUG: Extracted {len(messages)} messages")
return messages, None
@@ -569,16 +595,22 @@ class ComposioService:
return [], result.get("error", "Unknown error")
data = result.get("data", {})
- logger.info(f"DEBUG: Calendar data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}")
+ logger.info(
+ f"DEBUG: Calendar data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}"
+ )
logger.info(f"DEBUG: Calendar full data: {data}")
-
+
# Try different possible response structures
events = []
if isinstance(data, dict):
- events = data.get("items", []) or data.get("data", {}).get("items", []) or data.get("events", [])
+ events = (
+ data.get("items", [])
+ or data.get("data", {}).get("items", [])
+ or data.get("events", [])
+ )
elif isinstance(data, list):
events = data
-
+
logger.info(f"DEBUG: Extracted {len(events)} calendar events")
return events, None
diff --git a/surfsense_backend/app/services/notification_service.py b/surfsense_backend/app/services/notification_service.py
index 5f7f568f6..836daeb9e 100644
--- a/surfsense_backend/app/services/notification_service.py
+++ b/surfsense_backend/app/services/notification_service.py
@@ -623,6 +623,28 @@ class MentionNotificationHandler(BaseNotificationHandler):
def __init__(self):
super().__init__("new_mention")
+ async def find_notification_by_mention(
+ self,
+ session: AsyncSession,
+ mention_id: int,
+ ) -> Notification | None:
+ """
+ Find an existing notification by mention ID.
+
+ Args:
+ session: Database session
+ mention_id: The mention ID to search for
+
+ Returns:
+ Notification if found, None otherwise
+ """
+ query = select(Notification).where(
+ Notification.type == self.notification_type,
+ Notification.notification_metadata["mention_id"].astext == str(mention_id),
+ )
+ result = await session.execute(query)
+ return result.scalar_one_or_none()
+
async def notify_new_mention(
self,
session: AsyncSession,
@@ -641,11 +663,12 @@ class MentionNotificationHandler(BaseNotificationHandler):
) -> Notification:
"""
Create notification when a user is @mentioned in a comment.
+ Uses mention_id for idempotency to prevent duplicate notifications.
Args:
session: Database session
mentioned_user_id: User who was mentioned
- mention_id: ID of the mention record
+ mention_id: ID of the mention record (used for idempotency)
comment_id: ID of the comment containing the mention
message_id: ID of the message being commented on
thread_id: ID of the chat thread
@@ -658,8 +681,16 @@ class MentionNotificationHandler(BaseNotificationHandler):
search_space_id: Search space ID
Returns:
- Notification: The created notification
+ Notification: The created or existing notification
"""
+ # Check if notification already exists for this mention (idempotency)
+ existing = await self.find_notification_by_mention(session, mention_id)
+ if existing:
+ logger.info(
+ f"Notification already exists for mention {mention_id}, returning existing"
+ )
+ return existing
+
title = f"{author_name} mentioned you"
message = content_preview[:100] + ("..." if len(content_preview) > 100 else "")
@@ -676,21 +707,37 @@ class MentionNotificationHandler(BaseNotificationHandler):
"content_preview": content_preview[:200],
}
- notification = Notification(
- user_id=mentioned_user_id,
- search_space_id=search_space_id,
- type=self.notification_type,
- title=title,
- message=message,
- notification_metadata=metadata,
- )
- session.add(notification)
- await session.commit()
- await session.refresh(notification)
- logger.info(
- f"Created new_mention notification {notification.id} for user {mentioned_user_id}"
- )
- return notification
+ try:
+ notification = Notification(
+ user_id=mentioned_user_id,
+ search_space_id=search_space_id,
+ type=self.notification_type,
+ title=title,
+ message=message,
+ notification_metadata=metadata,
+ )
+ session.add(notification)
+ await session.commit()
+ await session.refresh(notification)
+ logger.info(
+ f"Created new_mention notification {notification.id} for user {mentioned_user_id}"
+ )
+ return notification
+ except Exception as e:
+ # Handle race condition - if duplicate key error, try to fetch existing
+ await session.rollback()
+ if (
+ "duplicate key" in str(e).lower()
+ or "unique constraint" in str(e).lower()
+ ):
+ logger.warning(
+ f"Duplicate notification detected for mention {mention_id}, fetching existing"
+ )
+ existing = await self.find_notification_by_mention(session, mention_id)
+ if existing:
+ return existing
+ # Re-raise if not a duplicate key error or couldn't find existing
+ raise
class NotificationService:
diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py
index 7d2cf4172..31229a59b 100644
--- a/surfsense_backend/app/tasks/chat/stream_new_chat.py
+++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py
@@ -11,6 +11,7 @@ Supports loading LLM configurations from:
import json
from collections.abc import AsyncGenerator
+from uuid import UUID
from langchain_core.messages import HumanMessage
from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,6 +28,10 @@ from app.agents.new_chat.llm_config import (
)
from app.db import Document, SurfsenseDocsDocument
from app.schemas.new_chat import ChatAttachment
+from app.services.chat_session_state_service import (
+ clear_ai_responding,
+ set_ai_responding,
+)
from app.services.connector_service import ConnectorService
from app.services.new_streaming_service import VercelStreamingService
@@ -167,9 +172,8 @@ async def stream_new_chat(
search_space_id: The search space ID
chat_id: The chat ID (used as LangGraph thread_id for memory)
session: The database session
- user_id: The current user's UUID string (for memory tools)
+ user_id: The current user's UUID string (for memory tools and session state)
llm_config_id: The LLM configuration ID (default: -1 for first global config)
- messages: Optional chat history from frontend (list of ChatMessage)
attachments: Optional attachments with extracted content
mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat
mentioned_surfsense_doc_ids: Optional list of SurfSense doc IDs mentioned with @ in the chat
@@ -183,6 +187,9 @@ async def stream_new_chat(
current_text_id: str | None = None
try:
+ # Mark AI as responding to this user for live collaboration
+ if user_id:
+ await set_ai_responding(session, chat_id, UUID(user_id))
# Load LLM config - supports both YAML (negative IDs) and database (positive IDs)
agent_config: AgentConfig | None = None
@@ -1147,3 +1154,7 @@ async def stream_new_chat(
yield streaming_service.format_finish_step()
yield streaming_service.format_finish()
yield streaming_service.format_done()
+
+ finally:
+ # Clear AI responding state for live collaboration
+ await clear_ai_responding(session, chat_id)
diff --git a/surfsense_backend/app/tasks/composio_indexer.py b/surfsense_backend/app/tasks/composio_indexer.py
index 01d2cfce4..abb238924 100644
--- a/surfsense_backend/app/tasks/composio_indexer.py
+++ b/surfsense_backend/app/tasks/composio_indexer.py
@@ -144,7 +144,9 @@ async def index_composio_connector(
# Get toolkit ID from config
toolkit_id = connector.config.get("toolkit_id")
if not toolkit_id:
- error_msg = f"Composio connector {connector_id} has no toolkit_id configured"
+ error_msg = (
+ f"Composio connector {connector_id} has no toolkit_id configured"
+ )
await task_logger.log_task_failure(
log_entry, error_msg, {"error_type": "MissingToolkitId"}
)
@@ -287,8 +289,14 @@ async def _index_composio_google_drive(
try:
# Handle both standard Google API and potential Composio variations
file_id = file_info.get("id", "") or file_info.get("fileId", "")
- file_name = file_info.get("name", "") or file_info.get("fileName", "") or "Untitled"
- mime_type = file_info.get("mimeType", "") or file_info.get("mime_type", "")
+ file_name = (
+ file_info.get("name", "")
+ or file_info.get("fileName", "")
+ or "Untitled"
+ )
+ mime_type = file_info.get("mimeType", "") or file_info.get(
+ "mime_type", ""
+ )
if not file_id:
documents_skipped += 1
@@ -309,12 +317,15 @@ async def _index_composio_google_drive(
)
# Get file content
- content, content_error = await composio_connector.get_drive_file_content(
- file_id
- )
+ (
+ content,
+ content_error,
+ ) = await composio_connector.get_drive_file_content(file_id)
if content_error or not content:
- logger.warning(f"Could not get content for file {file_name}: {content_error}")
+ logger.warning(
+ f"Could not get content for file {file_name}: {content_error}"
+ )
# Use metadata as content fallback
markdown_content = f"# {file_name}\n\n"
markdown_content += f"**File ID:** {file_id}\n"
@@ -344,12 +355,19 @@ async def _index_composio_google_drive(
"mime_type": mime_type,
"document_type": "Google Drive File (Composio)",
}
- summary_content, summary_embedding = await generate_document_summary(
+ (
+ summary_content,
+ summary_embedding,
+ ) = await generate_document_summary(
markdown_content, user_llm, document_metadata
)
else:
- summary_content = f"Google Drive File: {file_name}\n\nType: {mime_type}"
- summary_embedding = config.embedding_model_instance.embed(summary_content)
+ summary_content = (
+ f"Google Drive File: {file_name}\n\nType: {mime_type}"
+ )
+ summary_embedding = config.embedding_model_instance.embed(
+ summary_content
+ )
chunks = await create_document_chunks(markdown_content)
@@ -382,12 +400,19 @@ async def _index_composio_google_drive(
"mime_type": mime_type,
"document_type": "Google Drive File (Composio)",
}
- summary_content, summary_embedding = await generate_document_summary(
+ (
+ summary_content,
+ summary_embedding,
+ ) = await generate_document_summary(
markdown_content, user_llm, document_metadata
)
else:
- summary_content = f"Google Drive File: {file_name}\n\nType: {mime_type}"
- summary_embedding = config.embedding_model_instance.embed(summary_content)
+ summary_content = (
+ f"Google Drive File: {file_name}\n\nType: {mime_type}"
+ )
+ summary_embedding = config.embedding_model_instance.embed(
+ summary_content
+ )
chunks = await create_document_chunks(markdown_content)
@@ -527,11 +552,15 @@ async def _index_composio_gmail(
date_str = value
# Format to markdown using the full message data
- markdown_content = composio_connector.format_gmail_message_to_markdown(message)
+ markdown_content = composio_connector.format_gmail_message_to_markdown(
+ message
+ )
# Generate unique identifier
unique_identifier_hash = generate_unique_identifier_hash(
- DocumentType.COMPOSIO_CONNECTOR, f"gmail_{message_id}", search_space_id
+ DocumentType.COMPOSIO_CONNECTOR,
+ f"gmail_{message_id}",
+ search_space_id,
)
content_hash = generate_content_hash(markdown_content, search_space_id)
@@ -560,12 +589,19 @@ async def _index_composio_gmail(
"sender": sender,
"document_type": "Gmail Message (Composio)",
}
- summary_content, summary_embedding = await generate_document_summary(
+ (
+ summary_content,
+ summary_embedding,
+ ) = await generate_document_summary(
markdown_content, user_llm, document_metadata
)
else:
- summary_content = f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
- summary_embedding = config.embedding_model_instance.embed(summary_content)
+ summary_content = (
+ f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
+ )
+ summary_embedding = config.embedding_model_instance.embed(
+ summary_content
+ )
chunks = await create_document_chunks(markdown_content)
@@ -600,12 +636,19 @@ async def _index_composio_gmail(
"sender": sender,
"document_type": "Gmail Message (Composio)",
}
- summary_content, summary_embedding = await generate_document_summary(
+ (
+ summary_content,
+ summary_embedding,
+ ) = await generate_document_summary(
markdown_content, user_llm, document_metadata
)
else:
- summary_content = f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
- summary_embedding = config.embedding_model_instance.embed(summary_content)
+ summary_content = (
+ f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
+ )
+ summary_embedding = config.embedding_model_instance.embed(
+ summary_content
+ )
chunks = await create_document_chunks(markdown_content)
@@ -728,18 +771,24 @@ async def _index_composio_google_calendar(
try:
# Handle both standard Google API and potential Composio variations
event_id = event.get("id", "") or event.get("eventId", "")
- summary = event.get("summary", "") or event.get("title", "") or "No Title"
+ summary = (
+ event.get("summary", "") or event.get("title", "") or "No Title"
+ )
if not event_id:
documents_skipped += 1
continue
# Format to markdown
- markdown_content = composio_connector.format_calendar_event_to_markdown(event)
+ markdown_content = composio_connector.format_calendar_event_to_markdown(
+ event
+ )
# Generate unique identifier
unique_identifier_hash = generate_unique_identifier_hash(
- DocumentType.COMPOSIO_CONNECTOR, f"calendar_{event_id}", search_space_id
+ DocumentType.COMPOSIO_CONNECTOR,
+ f"calendar_{event_id}",
+ search_space_id,
)
content_hash = generate_content_hash(markdown_content, search_space_id)
@@ -772,14 +821,19 @@ async def _index_composio_google_calendar(
"start_time": start_time,
"document_type": "Google Calendar Event (Composio)",
}
- summary_content, summary_embedding = await generate_document_summary(
+ (
+ summary_content,
+ summary_embedding,
+ ) = await generate_document_summary(
markdown_content, user_llm, document_metadata
)
else:
summary_content = f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}"
if location:
summary_content += f"\nLocation: {location}"
- summary_embedding = config.embedding_model_instance.embed(summary_content)
+ summary_embedding = config.embedding_model_instance.embed(
+ summary_content
+ )
chunks = await create_document_chunks(markdown_content)
@@ -814,14 +868,21 @@ async def _index_composio_google_calendar(
"start_time": start_time,
"document_type": "Google Calendar Event (Composio)",
}
- summary_content, summary_embedding = await generate_document_summary(
+ (
+ summary_content,
+ summary_embedding,
+ ) = await generate_document_summary(
markdown_content, user_llm, document_metadata
)
else:
- summary_content = f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}"
+ summary_content = (
+ f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}"
+ )
if location:
summary_content += f"\nLocation: {location}"
- summary_embedding = config.embedding_model_instance.embed(summary_content)
+ summary_embedding = config.embedding_model_instance.embed(
+ summary_content
+ )
chunks = await create_document_chunks(markdown_content)
@@ -874,5 +935,7 @@ async def _index_composio_google_calendar(
return documents_indexed, None
except Exception as e:
- logger.error(f"Failed to index Google Calendar via Composio: {e!s}", exc_info=True)
+ logger.error(
+ f"Failed to index Google Calendar via Composio: {e!s}", exc_info=True
+ )
return 0, f"Failed to index Google Calendar via Composio: {e!s}"
diff --git a/surfsense_backend/app/tasks/connector_indexers/github_indexer.py b/surfsense_backend/app/tasks/connector_indexers/github_indexer.py
index f16ee0156..4a8df4918 100644
--- a/surfsense_backend/app/tasks/connector_indexers/github_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/github_indexer.py
@@ -128,7 +128,9 @@ async def index_github_repos(
if github_pat:
logger.info("Using GitHub PAT for authentication (private repos supported)")
else:
- logger.info("No GitHub PAT provided - only public repositories can be indexed")
+ logger.info(
+ "No GitHub PAT provided - only public repositories can be indexed"
+ )
# 3. Initialize GitHub connector with gitingest backend
await task_logger.log_task_progress(
@@ -308,9 +310,7 @@ async def _process_repository_digest(
if existing_document:
# Document exists - check if content has changed
if existing_document.content_hash == content_hash:
- logger.info(
- f"Repository {repo_full_name} unchanged. Skipping."
- )
+ logger.info(f"Repository {repo_full_name} unchanged. Skipping.")
return 0
else:
logger.info(
@@ -341,7 +341,7 @@ async def _process_repository_digest(
summary_content = (
f"# Repository: {repo_full_name}\n\n"
f"## File Structure\n\n{digest.tree}\n\n"
- f"## File Contents (truncated)\n\n{digest.content[:MAX_DIGEST_CHARS - len(digest.tree) - 200]}..."
+ f"## File Contents (truncated)\n\n{digest.content[: MAX_DIGEST_CHARS - len(digest.tree) - 200]}..."
)
summary_text, summary_embedding = await generate_document_summary(
@@ -362,9 +362,7 @@ async def _process_repository_digest(
# This preserves file-level granularity in search
chunks_data = await create_document_chunks(digest.content)
except Exception as chunk_err:
- logger.error(
- f"Failed to chunk repository {repo_full_name}: {chunk_err}"
- )
+ logger.error(f"Failed to chunk repository {repo_full_name}: {chunk_err}")
# Fall back to a simpler chunking approach
chunks_data = await _simple_chunk_content(digest.content)
diff --git a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx
index 1631f00b9..e434d85fb 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx
@@ -39,12 +39,6 @@ export default function DashboardLayout({
icon: "SquareLibrary",
items: [],
},
- {
- title: "Logs",
- url: `/dashboard/${search_space_id}/logs`,
- icon: "Logs",
- items: [],
- },
];
return (
diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
index c20436a5e..4d43b7f64 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
@@ -24,6 +24,7 @@ import {
// extractWriteTodosFromContent,
hydratePlanStateAtom,
} from "@/atoms/chat/plan-state.atom";
+import { membersAtom } from "@/atoms/members/members-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Thread } from "@/components/assistant-ui/thread";
import { ChatHeader } from "@/components/new-chat/chat-header";
@@ -32,7 +33,9 @@ import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
-import { SaveMemoryToolUI, RecallMemoryToolUI } from "@/components/tool-ui/user-memory";
+import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
+import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
+import { useMessagesElectric } from "@/hooks/use-messages-electric";
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
import { getBearerToken } from "@/lib/auth-utils";
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
@@ -258,6 +261,53 @@ export default function NewChatPage() {
// Get current user for author info in shared chats
const { data: currentUser } = useAtomValue(currentUserAtom);
+ // Live collaboration: sync session state and messages via Electric SQL
+ useChatSessionStateSync(threadId);
+ const { data: membersData } = useAtomValue(membersAtom);
+
+ const handleElectricMessagesUpdate = useCallback(
+ (
+ electricMessages: {
+ id: number;
+ thread_id: number;
+ role: string;
+ content: unknown;
+ author_id: string | null;
+ created_at: string;
+ }[]
+ ) => {
+ if (isRunning) {
+ return;
+ }
+
+ setMessages((prev) => {
+ if (electricMessages.length < prev.length) {
+ return prev;
+ }
+
+ return electricMessages.map((msg) => {
+ const member = msg.author_id
+ ? membersData?.find((m) => m.user_id === msg.author_id)
+ : null;
+
+ return convertToThreadMessage({
+ id: msg.id,
+ thread_id: msg.thread_id,
+ role: msg.role.toLowerCase() as "user" | "assistant" | "system",
+ content: msg.content,
+ author_id: msg.author_id,
+ created_at: msg.created_at,
+ author_display_name: member?.user_display_name ?? null,
+ author_avatar_url: member?.user_avatar_url ?? null,
+ });
+ });
+ });
+ },
+ [isRunning, membersData]
+ );
+
+ useMessagesElectric(threadId, handleElectricMessagesUpdate);
+
// Create the attachment adapter for file processing
const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []);
@@ -368,7 +418,7 @@ export default function NewChatPage() {
initializeThread();
}, [initializeThread]);
- // Handle scroll to comment from URL query params (e.g., from notification click)
+ // Handle scroll to comment from URL query params (e.g., from inbox item click)
const searchParams = useSearchParams();
const targetCommentId = searchParams.get("commentId");
@@ -587,8 +637,6 @@ export default function NewChatPage() {
content: persistContent,
})
.then(() => {
- // For new threads, the backend updates the title from the first user message
- // Invalidate threads query so sidebar shows the updated title in real-time
if (isNewThread) {
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
index 6701342de..b661e9222 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
@@ -3,29 +3,38 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import {
+ Bot,
Calendar,
Check,
Clock,
Copy,
Crown,
Edit2,
+ FileText,
Hash,
Link2,
LinkIcon,
Loader2,
+ Logs,
+ type LucideIcon,
+ MessageCircle,
+ MessageSquare,
+ Mic,
MoreHorizontal,
+ Plug,
Plus,
RefreshCw,
Search,
+ Settings,
Shield,
ShieldCheck,
Trash2,
- User,
UserMinus,
UserPlus,
Users,
} from "lucide-react";
import { motion } from "motion/react";
+import Image from "next/image";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -512,6 +521,25 @@ export default function TeamManagementPage() {
// ============ Members Tab ============
+// Helper function to get avatar initials
+function getAvatarInitials(member: Membership): string {
+ // Try display name first
+ if (member.user_display_name) {
+ const parts = member.user_display_name.trim().split(/\s+/);
+ if (parts.length >= 2) {
+ return (parts[0][0] + parts[1][0]).toUpperCase();
+ }
+ return member.user_display_name.slice(0, 2).toUpperCase();
+ }
+ // Try email
+ if (member.user_email) {
+ const emailName = member.user_email.split("@")[0];
+ return emailName.slice(0, 2).toUpperCase();
+ }
+ // Fallback
+ return "U";
+}
+
function MembersTab({
members,
roles,
@@ -560,7 +588,7 @@ function MembersTab({
setSearchQuery(e.target.value)}
className="pl-9"
@@ -573,10 +601,30 @@ function MembersTab({
- Member
- Role
- Joined
- Actions
+
+
+
+ Member
+
+
+
+
+
+ Role
+
+
+
+
+
+ Joined
+
+
+
+
+
+ Actions
+
+
@@ -601,19 +649,36 @@ function MembersTab({
-
-
-
+ {member.user_avatar_url ? (
+
+ ) : (
+
+
+ {getAvatarInitials(member)}
+
+
+ )}
{member.is_owner && (
-
- {member.user_email || "Unknown"}
+ {member.user_display_name || member.user_email || "Unknown"}
+ {member.user_display_name && member.user_email && (
+
+ {member.user_email}
+
+ )}
{member.is_owner && (
No role
{roles.map((role) => (
-
-
- {role.name}
-
+ {role.name}
))}
) : (
-
-
+
{member.role?.name || "No role"}
)}
-
-
+
{new Date(member.joined_at).toLocaleDateString()}
-
+
{canRemove && !member.is_owner && (
@@ -708,13 +765,137 @@ function MembersTab({
);
}
+// ============ Role Permissions Display ============
+
+const CATEGORY_CONFIG: Record = {
+ documents: { label: "Documents", icon: FileText, order: 1 },
+ chats: { label: "Chats", icon: MessageSquare, order: 2 },
+ comments: { label: "Comments", icon: MessageCircle, order: 3 },
+ llm_configs: { label: "LLM Configs", icon: Bot, order: 4 },
+ podcasts: { label: "Podcasts", icon: Mic, order: 5 },
+ connectors: { label: "Connectors", icon: Plug, order: 6 },
+ logs: { label: "Logs", icon: Logs, order: 7 },
+ members: { label: "Members", icon: Users, order: 8 },
+ roles: { label: "Roles", icon: Shield, order: 9 },
+ settings: { label: "Settings", icon: Settings, order: 10 },
+};
+
+const ACTION_LABELS: Record = {
+ create: "Create",
+ read: "Read",
+ update: "Update",
+ delete: "Delete",
+ invite: "Invite",
+ view: "View",
+ remove: "Remove",
+ manage_roles: "Manage Roles",
+};
+
+function RolePermissionsDisplay({ permissions }: { permissions: string[] }) {
+ if (permissions.includes("*")) {
+ return (
+
+
+
+
+
+
Full Access
+
All permissions granted
+
+
+ );
+ }
+
+ // Group permissions by category
+ const grouped: Record = {};
+ for (const perm of permissions) {
+ const [category, action] = perm.split(":");
+ if (!grouped[category]) grouped[category] = [];
+ grouped[category].push(action);
+ }
+
+ // Sort categories by predefined order
+ const sortedCategories = Object.keys(grouped).sort((a, b) => {
+ const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
+ const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
+ return orderA - orderB;
+ });
+
+ const categoryCount = sortedCategories.length;
+
+ return (
+
+
+
+
+
+
+
+
+
{permissions.length} Permissions
+
+ Across {categoryCount} {categoryCount === 1 ? "category" : "categories"}
+
+
+
+ View details
+
+
+
+
+
+
+ Role Permissions
+
+
+ {permissions.length} permissions across {categoryCount} categories
+
+
+
+
+ {sortedCategories.map((category) => {
+ const actions = grouped[category];
+ const config = CATEGORY_CONFIG[category] || { label: category, icon: FileText };
+ const IconComponent = config.icon;
+ return (
+
+
+
+ {config.label}
+
+
+ {actions.map((action) => (
+
+ {ACTION_LABELS[action] || action.replace(/_/g, " ")}
+
+ ))}
+
+
+ );
+ })}
+
+
+
+
+ );
+}
+
// ============ Roles Tab ============
function RolesTab({
roles,
- groupedPermissions,
+ groupedPermissions: _groupedPermissions,
loading,
- onUpdateRole,
+ onUpdateRole: _onUpdateRole,
onDeleteRole,
canUpdate,
canDelete,
@@ -852,32 +1033,7 @@ function RolesTab({
)}
-
-
- Permissions ({role.permissions.includes("*") ? "All" : role.permissions.length})
-
-
- {role.permissions.includes("*") ? (
-
- Full Access
-
- ) : (
- role.permissions.slice(0, 5).map((perm) => (
-
- {perm.replace(":", " ")}
-
- ))
- )}
- {!role.permissions.includes("*") && role.permissions.length > 5 && (
-
- +{role.permissions.length - 5} more
-
- )}
-
-
+
@@ -1500,7 +1656,11 @@ function CreateRoleDialog({
return (
-
+ toggleCategory(category)}
+ >
toggleCategory(category)}
@@ -1508,19 +1668,21 @@ function CreateRoleDialog({
{category} ({categorySelected}/{perms.length})
-
+
{perms.map((perm) => (
- togglePermission(perm.value)}
>
togglePermission(perm.value)}
/>
{perm.value.split(":")[1]}
-
+
))}
diff --git a/surfsense_web/atoms/chat/chat-session-state.atom.ts b/surfsense_web/atoms/chat/chat-session-state.atom.ts
new file mode 100644
index 000000000..4d83a45d4
--- /dev/null
+++ b/surfsense_web/atoms/chat/chat-session-state.atom.ts
@@ -0,0 +1,15 @@
+"use client";
+
+import { atom } from "jotai";
+
+export interface ChatSessionStateData {
+ threadId: number;
+ isAiResponding: boolean;
+ respondingToUserId: string | null;
+}
+
+/**
+ * Global chat session state atom.
+ * Updated by useChatSessionStateSync hook, read anywhere.
+ */
+export const chatSessionStateAtom = atom(null);
diff --git a/surfsense_web/atoms/members/members-query.atoms.ts b/surfsense_web/atoms/members/members-query.atoms.ts
index 8ed56ef0c..f486dc02b 100644
--- a/surfsense_web/atoms/members/members-query.atoms.ts
+++ b/surfsense_web/atoms/members/members-query.atoms.ts
@@ -9,7 +9,7 @@ export const membersAtom = atomWithQuery((get) => {
return {
queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""),
enabled: !!searchSpaceId,
- staleTime: 5 * 60 * 1000, // 5 minutes
+ staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration
queryFn: async () => {
if (!searchSpaceId) {
return [];
diff --git a/surfsense_web/components/assistant-ui/chat-session-status.tsx b/surfsense_web/components/assistant-ui/chat-session-status.tsx
new file mode 100644
index 000000000..88fea6b8c
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/chat-session-status.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { Loader2 } from "lucide-react";
+import type { FC } from "react";
+import { cn } from "@/lib/utils";
+
+interface ChatSessionStatusProps {
+ isAiResponding: boolean;
+ respondingToUserId: string | null;
+ currentUserId: string | null;
+ members: Array<{
+ user_id: string;
+ user_display_name?: string | null;
+ user_email?: string | null;
+ }>;
+ className?: string;
+}
+
+export const ChatSessionStatus: FC = ({
+ isAiResponding,
+ respondingToUserId,
+ currentUserId,
+ members,
+ className,
+}) => {
+ if (!isAiResponding || !respondingToUserId) {
+ return null;
+ }
+
+ if (respondingToUserId === currentUserId) {
+ return null;
+ }
+
+ const respondingUser = members.find((m) => m.user_id === respondingToUserId);
+ const displayName =
+ respondingUser?.user_display_name || respondingUser?.user_email || "another user";
+
+ return (
+
+
+ Currently responding to {displayName}
+
+ );
+};
diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx
index 1f4341d07..72e330770 100644
--- a/surfsense_web/components/assistant-ui/connector-popup.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup.tsx
@@ -186,12 +186,10 @@ export const ConnectorIndicator: FC = () => {
) : viewingComposio && searchSpaceId ? (
c.connector_type === "COMPOSIO_CONNECTOR")
- .map((c: SearchSourceConnector) => c.config?.toolkit_id as string)
- .filter(Boolean)
- }
+ connectedToolkits={(connectors || [])
+ .filter((c: SearchSourceConnector) => c.connector_type === "COMPOSIO_CONNECTOR")
+ .map((c: SearchSourceConnector) => c.config?.toolkit_id as string)
+ .filter(Boolean)}
onBack={handleBackFromComposio}
onConnectToolkit={handleConnectComposioToolkit}
isConnecting={connectingComposioToolkit !== null}
diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
index 8c7e51465..1d0992a1b 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
@@ -6,7 +6,12 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { isSelfHosted } from "@/lib/env-config";
import { ConnectorCard } from "../components/connector-card";
import { ComposioConnectorCard } from "../components/composio-connector-card";
-import { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS, COMPOSIO_CONNECTORS } from "../constants/connector-constants";
+import {
+ COMPOSIO_CONNECTORS,
+ CRAWLERS,
+ OAUTH_CONNECTORS,
+ OTHER_CONNECTORS,
+} from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
/**
@@ -164,7 +169,7 @@ export const AllConnectorsTab: FC = ({
)}
{/* Composio Integrations */}
- {filteredComposio.length > 0 && onOpenComposio && (
+ {/* {filteredComposio.length > 0 && onOpenComposio && (
Managed OAuth
@@ -185,7 +190,7 @@ export const AllConnectorsTab: FC
= ({
))}
- )}
+ )} */}
{/* More Integrations */}
{filteredOther.length > 0 && (
diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/composio-toolkit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/composio-toolkit-view.tsx
index 456835597..9c0bd7223 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/views/composio-toolkit-view.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/views/composio-toolkit-view.tsx
@@ -5,12 +5,12 @@ import {
Calendar,
Check,
ExternalLink,
+ FileText,
Github,
+ HardDrive,
Loader2,
Mail,
- HardDrive,
MessageSquare,
- FileText,
Zap,
} from "lucide-react";
import Image from "next/image";
@@ -82,17 +82,65 @@ const getToolkitIcon = (toolkitId: string, className?: string) => {
switch (toolkitId) {
case "googledrive":
- return ;
+ return (
+
+ );
case "gmail":
- return ;
+ return (
+
+ );
case "googlecalendar":
- return ;
+ return (
+
+ );
case "slack":
- return ;
+ return (
+
+ );
case "notion":
- return ;
+ return (
+
+ );
case "github":
- return ;
+ return (
+
+ );
default:
return ;
}
@@ -139,9 +187,7 @@ export const ComposioToolkitView: FC = ({
/>
-
- Composio
-
+
Composio
Connect 100+ apps with managed OAuth - no verification needed
@@ -165,12 +211,16 @@ export const ComposioToolkitView: FC
= ({
Google Services
-
+
Indexable
- Connect Google services via Composio's verified OAuth app. Your data will be indexed and searchable.
+ Connect Google services via Composio's verified OAuth app. Your data will be
+ indexed and searchable.
{indexableToolkits.map((toolkit) => {
@@ -201,16 +251,17 @@ export const ComposioToolkitView: FC = ({
{getToolkitIcon(toolkit.id, "size-5")}
{isConnected && (
-
+
Connected
)}
{toolkit.name}
-
- {toolkit.description}
-
+
{toolkit.description}
= ({
More Integrations
-
+
Coming Soon
- Connect these services for future indexing support. Currently available for connection only.
+ Connect these services for future indexing support. Currently available for connection
+ only.
{nonIndexableToolkits.map((toolkit) => (
@@ -264,9 +319,7 @@ export const ComposioToolkitView: FC = ({
{toolkit.name}
-
- {toolkit.description}
-
+ {toolkit.description}
= ({
Why use Composio?
- Composio provides pre-verified OAuth apps, so you don't need to wait for Google app verification.
- Your data is securely processed through Composio's managed authentication.
+ Composio provides pre-verified OAuth apps, so you don't need to wait for Google
+ app verification. Your data is securely processed through Composio's managed
+ authentication.
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx
index eaf30fc96..042b932a4 100644
--- a/surfsense_web/components/assistant-ui/thread.tsx
+++ b/surfsense_web/components/assistant-ui/thread.tsx
@@ -26,11 +26,13 @@ import {
import { useParams } from "next/navigation";
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
+import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
import {
mentionedDocumentIdsAtom,
mentionedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
+import { membersAtom } from "@/atoms/members/members-query.atoms";
import {
globalNewLLMConfigsAtom,
llmPreferencesAtom,
@@ -39,6 +41,7 @@ import {
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
+import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import {
InlineMentionEditor,
@@ -59,6 +62,7 @@ import {
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { Button } from "@/components/ui/button";
import type { Document } from "@/contracts/types/document.types";
+import { useCommentsElectric } from "@/hooks/use-comments-electric";
import { cn } from "@/lib/utils";
interface ThreadProps {
@@ -86,6 +90,7 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
>
{
const editorRef = useRef(null);
const editorContainerRef = useRef(null);
const documentPickerRef = useRef(null);
- const { search_space_id } = useParams();
+ const { search_space_id, chat_id } = useParams();
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
const composerRuntime = useComposerRuntime();
const hasAutoFocusedRef = useRef(false);
@@ -223,6 +228,23 @@ const Composer: FC = () => {
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
+ // Live collaboration state
+ const { data: currentUser } = useAtomValue(currentUserAtom);
+ const { data: members } = useAtomValue(membersAtom);
+ const threadId = useMemo(() => {
+ if (Array.isArray(chat_id) && chat_id.length > 0) {
+ return Number.parseInt(chat_id[0], 10) || null;
+ }
+ return typeof chat_id === "string" ? Number.parseInt(chat_id, 10) || null : null;
+ }, [chat_id]);
+ const sessionState = useAtomValue(chatSessionStateAtom);
+ const isAiResponding = sessionState?.isAiResponding ?? false;
+ const respondingToUserId = sessionState?.respondingToUserId ?? null;
+ const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id;
+
+ // Sync comments for the entire thread via Electric SQL (one subscription per thread)
+ useCommentsElectric(threadId);
+
// Auto-focus editor on new chat page after mount
useEffect(() => {
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
@@ -298,9 +320,9 @@ const Composer: FC = () => {
[showDocumentPopover]
);
- // Submit message (blocked during streaming or when document picker is open)
+ // Submit message (blocked during streaming, document picker open, or AI responding to another user)
const handleSubmit = useCallback(() => {
- if (isThreadRunning) {
+ if (isThreadRunning || isBlockedByOtherUser) {
return;
}
if (!showDocumentPopover) {
@@ -315,6 +337,7 @@ const Composer: FC = () => {
}, [
showDocumentPopover,
isThreadRunning,
+ isBlockedByOtherUser,
composerRuntime,
setMentionedDocuments,
setMentionedDocumentIds,
@@ -374,7 +397,13 @@ const Composer: FC = () => {
);
return (
-
+
+
{/* Inline editor with @mention support */}
@@ -417,13 +446,17 @@ const Composer: FC = () => {
/>,
document.body
)}
-
+
);
};
-const ComposerAction: FC = () => {
+interface ComposerActionProps {
+ isBlockedByOtherUser?: boolean;
+}
+
+const ComposerAction: FC = ({ isBlockedByOtherUser = false }) => {
// Check if any attachments are still being processed (running AND progress < 100)
// When progress is 100, processing is done but waiting for send()
const hasProcessingAttachments = useAssistantState(({ composer }) =>
@@ -458,7 +491,8 @@ const ComposerAction: FC = () => {
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
}, [preferences, globalConfigs, userConfigs]);
- const isSendDisabled = hasProcessingAttachments || isComposerEmpty || !hasModelConfigured;
+ const isSendDisabled =
+ hasProcessingAttachments || isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
return (
@@ -487,13 +521,15 @@ const ComposerAction: FC = () => {
{hasThreads && (
-
+
{threads.map((thread) => (
)}
-
+
+
+
+
+
+
+ Comments
+ {commentCount > 0 && (
+
+ {commentCount}
+
+ )}
+
+
+
+
+
+
+
+ );
+ }
+
+ // Use Sheet for medium screens (right side)
return (
- {/* Drag handle indicator - only for bottom sheet */}
- {isBottomSheet && (
-
- )}
-
+
Comments
diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx
index d1b5d3bc1..96bd0ef30 100644
--- a/surfsense_web/components/dashboard-breadcrumb.tsx
+++ b/surfsense_web/components/dashboard-breadcrumb.tsx
@@ -179,7 +179,7 @@ export function DashboardBreadcrumb() {
const breadcrumbs = generateBreadcrumbs(pathname);
- if (breadcrumbs.length <= 1) {
+ if (breadcrumbs.length === 0) {
return null; // Don't show breadcrumbs for root dashboard
}
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index 489fde3d7..52dc7196a 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
-import { LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react";
+import { Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@@ -19,6 +19,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
+import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
import { cleanupElectric } from "@/lib/electric/client";
@@ -29,6 +30,7 @@ import { CreateSearchSpaceDialog } from "../ui/dialogs";
import { LayoutShell } from "../ui/shell";
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
+import { InboxSidebar } from "../ui/sidebar/InboxSidebar";
interface LayoutDataProviderProps {
searchSpaceId: string;
@@ -59,8 +61,8 @@ export function LayoutDataProvider({
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
: null;
- // Fetch current search space
- const { data: searchSpace } = useQuery({
+ // Fetch current search space (for caching purposes)
+ useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
enabled: !!searchSpaceId,
@@ -77,9 +79,25 @@ export function LayoutDataProvider({
const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false);
const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false);
+ // Inbox sidebar state
+ const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
+
// Search space dialog state
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
+ // Inbox hook
+ const userId = user?.id ? String(user.id) : null;
+ const {
+ inboxItems,
+ unreadCount,
+ loading: inboxLoading,
+ loadingMore: inboxLoadingMore,
+ hasMore: inboxHasMore,
+ loadMore: inboxLoadMore,
+ markAsRead,
+ markAllAsRead,
+ } = useInbox(userId, Number(searchSpaceId) || null, null);
+
// Delete dialogs state
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
@@ -150,13 +168,14 @@ export function LayoutDataProvider({
isActive: pathname?.includes("/documents"),
},
{
- title: "Logs",
- url: `/dashboard/${searchSpaceId}/logs`,
- icon: Logs,
- isActive: pathname?.includes("/logs"),
+ title: "Inbox",
+ url: "#inbox", // Special URL to indicate this is handled differently
+ icon: Inbox,
+ isActive: isInboxSidebarOpen,
+ badge: unreadCount > 0 ? (unreadCount > 99 ? "99+" : unreadCount) : undefined,
},
],
- [searchSpaceId, pathname]
+ [searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]
);
// Handlers
@@ -248,6 +267,11 @@ export function LayoutDataProvider({
const handleNavItemClick = useCallback(
(item: NavItem) => {
+ // Handle inbox specially - open sidebar instead of navigating
+ if (item.url === "#inbox") {
+ setIsInboxSidebarOpen(true);
+ return;
+ }
router.push(item.url);
},
[router]
@@ -517,6 +541,20 @@ export function LayoutDataProvider({
searchSpaceId={searchSpaceId}
/>
+ {/* Inbox Sidebar */}
+
+
{/* Create Search Space Dialog */}
- {/* Notifications */}
-
{/* Share button - only show on chat pages when thread exists */}
{hasThread && (
diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
index f5c64cc67..39f1b95bc 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
@@ -28,6 +28,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import {
@@ -237,20 +238,9 @@ export function AllPrivateChatsSidebar({
aria-label={t("chats") || "Private Chats"}
>
-
-
-
-
{t("chats") || "Private Chats"}
-
-
onOpenChange(false)}
- >
-
- Close
-
+
+
+
{t("chats") || "Private Chats"}
@@ -277,32 +267,38 @@ export function AllPrivateChatsSidebar({
{!isSearchMode && (
-
- setShowArchived(false)}
- className={cn(
- "flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
- !showArchived
- ? "border-b-2 border-primary text-primary"
- : "text-muted-foreground hover:text-foreground"
- )}
- >
- Active ({activeCount})
-
- setShowArchived(true)}
- className={cn(
- "flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
- showArchived
- ? "border-b-2 border-primary text-primary"
- : "text-muted-foreground hover:text-foreground"
- )}
- >
- Archived ({archivedCount})
-
-
+
setShowArchived(value === "archived")}
+ className="shrink-0 mx-4"
+ >
+
+
+
+
+ Active
+
+ {activeCount}
+
+
+
+
+
+
+ Archived
+
+ {archivedCount}
+
+
+
+
+
)}
@@ -371,7 +367,7 @@ export function AllPrivateChatsSidebar({
{isDeleting ? (
) : (
-
+
)}
{t("more_options") || "More options"}
diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
index f50cb028a..8dd593945 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
@@ -28,6 +28,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import {
@@ -237,20 +238,9 @@ export function AllSharedChatsSidebar({
aria-label={t("shared_chats") || "Shared Chats"}
>
-
-
-
-
{t("shared_chats") || "Shared Chats"}
-
-
onOpenChange(false)}
- >
-
- Close
-
+
+
+
{t("shared_chats") || "Shared Chats"}
@@ -277,32 +267,38 @@ export function AllSharedChatsSidebar({
{!isSearchMode && (
-
- setShowArchived(false)}
- className={cn(
- "flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
- !showArchived
- ? "border-b-2 border-primary text-primary"
- : "text-muted-foreground hover:text-foreground"
- )}
- >
- Active ({activeCount})
-
- setShowArchived(true)}
- className={cn(
- "flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
- showArchived
- ? "border-b-2 border-primary text-primary"
- : "text-muted-foreground hover:text-foreground"
- )}
- >
- Archived ({archivedCount})
-
-
+
setShowArchived(value === "archived")}
+ className="shrink-0 mx-4"
+ >
+
+
+
+
+ Active
+
+ {activeCount}
+
+
+
+
+
+
+ Archived
+
+ {archivedCount}
+
+
+
+
+
)}
@@ -371,7 +367,7 @@ export function AllSharedChatsSidebar({
{isDeleting ? (
) : (
-
+
)}
{t("more_options") || "More options"}
diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
index 7f5ede04c..5dd9c2cfa 100644
--- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
@@ -39,11 +39,11 @@ export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItem
{/* Actions dropdown */}
-
+
-
+
{t("more_options")}
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
new file mode 100644
index 000000000..810e3a22e
--- /dev/null
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -0,0 +1,854 @@
+"use client";
+
+import {
+ AlertCircle,
+ AtSign,
+ BellDot,
+ Check,
+ CheckCheck,
+ CheckCircle2,
+ History,
+ Inbox,
+ LayoutGrid,
+ ListFilter,
+ Search,
+ X,
+} from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import {
+ Drawer,
+ DrawerContent,
+ DrawerHandle,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { Spinner } from "@/components/ui/spinner";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
+import {
+ type ConnectorIndexingMetadata,
+ isConnectorIndexingMetadata,
+ isNewMentionMetadata,
+ type NewMentionMetadata,
+} from "@/contracts/types/inbox.types";
+import type { InboxItem } from "@/hooks/use-inbox";
+import { useMediaQuery } from "@/hooks/use-media-query";
+import { cn } from "@/lib/utils";
+
+/**
+ * Get initials from name or email for avatar fallback
+ */
+function getInitials(name: string | null | undefined, email: string | null | undefined): string {
+ if (name) {
+ return name
+ .split(" ")
+ .map((n) => n[0])
+ .join("")
+ .toUpperCase()
+ .slice(0, 2);
+ }
+ if (email) {
+ const localPart = email.split("@")[0];
+ return localPart.slice(0, 2).toUpperCase();
+ }
+ return "U";
+}
+
+/**
+ * Get display name for connector type
+ */
+function getConnectorTypeDisplayName(connectorType: string): string {
+ const displayNames: Record = {
+ GITHUB_CONNECTOR: "GitHub",
+ GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
+ GOOGLE_GMAIL_CONNECTOR: "Gmail",
+ GOOGLE_DRIVE_CONNECTOR: "Google Drive",
+ LINEAR_CONNECTOR: "Linear",
+ NOTION_CONNECTOR: "Notion",
+ SLACK_CONNECTOR: "Slack",
+ TEAMS_CONNECTOR: "Microsoft Teams",
+ DISCORD_CONNECTOR: "Discord",
+ JIRA_CONNECTOR: "Jira",
+ CONFLUENCE_CONNECTOR: "Confluence",
+ BOOKSTACK_CONNECTOR: "BookStack",
+ CLICKUP_CONNECTOR: "ClickUp",
+ AIRTABLE_CONNECTOR: "Airtable",
+ LUMA_CONNECTOR: "Luma",
+ ELASTICSEARCH_CONNECTOR: "Elasticsearch",
+ WEBCRAWLER_CONNECTOR: "Web Crawler",
+ YOUTUBE_CONNECTOR: "YouTube",
+ CIRCLEBACK_CONNECTOR: "Circleback",
+ MCP_CONNECTOR: "MCP",
+ TAVILY_API: "Tavily",
+ SEARXNG_API: "SearXNG",
+ LINKUP_API: "Linkup",
+ BAIDU_SEARCH_API: "Baidu",
+ };
+
+ return (
+ displayNames[connectorType] ||
+ connectorType
+ .replace(/_/g, " ")
+ .replace(/CONNECTOR|API/gi, "")
+ .trim()
+ );
+}
+
+type InboxTab = "mentions" | "status";
+type InboxFilter = "all" | "unread";
+
+interface InboxSidebarProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ inboxItems: InboxItem[];
+ unreadCount: number;
+ loading: boolean;
+ loadingMore?: boolean;
+ hasMore?: boolean;
+ loadMore?: () => void;
+ markAsRead: (id: number) => Promise;
+ markAllAsRead: () => Promise;
+ onCloseMobileSidebar?: () => void;
+}
+
+export function InboxSidebar({
+ open,
+ onOpenChange,
+ inboxItems,
+ unreadCount,
+ loading,
+ loadingMore = false,
+ hasMore = false,
+ loadMore,
+ markAsRead,
+ markAllAsRead,
+ onCloseMobileSidebar,
+}: InboxSidebarProps) {
+ const t = useTranslations("sidebar");
+ const router = useRouter();
+ const isMobile = !useMediaQuery("(min-width: 640px)");
+
+ const [searchQuery, setSearchQuery] = useState("");
+ const [activeTab, setActiveTab] = useState("mentions");
+ const [activeFilter, setActiveFilter] = useState("all");
+ const [selectedConnector, setSelectedConnector] = useState(null);
+ const [mounted, setMounted] = useState(false);
+ // Dropdown state for filter menu (desktop only)
+ const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
+ // Drawer state for filter menu (mobile only)
+ const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
+ const [markingAsReadId, setMarkingAsReadId] = useState(null);
+
+ // Prefetch trigger ref - placed on item near the end
+ const prefetchTriggerRef = useRef(null);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && open) {
+ onOpenChange(false);
+ }
+ };
+ document.addEventListener("keydown", handleEscape);
+ return () => document.removeEventListener("keydown", handleEscape);
+ }, [open, onOpenChange]);
+
+ useEffect(() => {
+ if (open) {
+ document.body.style.overflow = "hidden";
+ } else {
+ document.body.style.overflow = "";
+ }
+ return () => {
+ document.body.style.overflow = "";
+ };
+ }, [open]);
+
+ // Reset connector filter when switching away from status tab
+ useEffect(() => {
+ if (activeTab !== "status") {
+ setSelectedConnector(null);
+ }
+ }, [activeTab]);
+
+ // Split items by type
+ const mentionItems = useMemo(
+ () => inboxItems.filter((item) => item.type === "new_mention"),
+ [inboxItems]
+ );
+
+ const statusItems = useMemo(
+ () =>
+ inboxItems.filter(
+ (item) => item.type === "connector_indexing" || item.type === "document_processing"
+ ),
+ [inboxItems]
+ );
+
+ // Get unique connector types from status items for filtering
+ const uniqueConnectorTypes = useMemo(() => {
+ const connectorTypes = new Set();
+
+ statusItems
+ .filter((item) => item.type === "connector_indexing")
+ .forEach((item) => {
+ // Use type guard for safe metadata access
+ if (isConnectorIndexingMetadata(item.metadata)) {
+ connectorTypes.add(item.metadata.connector_type);
+ }
+ });
+
+ return Array.from(connectorTypes).map((type) => ({
+ type,
+ displayName: getConnectorTypeDisplayName(type),
+ }));
+ }, [statusItems]);
+
+ // Get items for current tab
+ const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems;
+
+ // Filter items based on filter type, connector filter, and search query
+ const filteredItems = useMemo(() => {
+ let items = currentTabItems;
+
+ // Apply read/unread filter
+ if (activeFilter === "unread") {
+ items = items.filter((item) => !item.read);
+ }
+
+ // Apply connector filter (only for status tab)
+ if (activeTab === "status" && selectedConnector) {
+ items = items.filter((item) => {
+ if (item.type === "connector_indexing") {
+ // Use type guard for safe metadata access
+ if (isConnectorIndexingMetadata(item.metadata)) {
+ return item.metadata.connector_type === selectedConnector;
+ }
+ return false;
+ }
+ return false; // Hide document_processing when a specific connector is selected
+ });
+ }
+
+ // Apply search query
+ if (searchQuery.trim()) {
+ const query = searchQuery.toLowerCase();
+ items = items.filter(
+ (item) =>
+ item.title.toLowerCase().includes(query) || item.message.toLowerCase().includes(query)
+ );
+ }
+
+ return items;
+ }, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]);
+
+ // Intersection Observer for infinite scroll with prefetching
+ // Only active when not searching (search results are client-side filtered)
+ useEffect(() => {
+ if (!loadMore || !hasMore || loadingMore || !open || searchQuery.trim()) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ // When trigger element is visible, load more
+ if (entries[0]?.isIntersecting) {
+ loadMore();
+ }
+ },
+ {
+ root: null, // viewport
+ rootMargin: "100px", // Start loading 100px before visible
+ threshold: 0,
+ }
+ );
+
+ if (prefetchTriggerRef.current) {
+ observer.observe(prefetchTriggerRef.current);
+ }
+
+ return () => observer.disconnect();
+ }, [loadMore, hasMore, loadingMore, open, searchQuery, filteredItems.length]);
+
+ // Count unread items per tab
+ const unreadMentionsCount = useMemo(() => {
+ return mentionItems.filter((item) => !item.read).length;
+ }, [mentionItems]);
+
+ const unreadStatusCount = useMemo(() => {
+ return statusItems.filter((item) => !item.read).length;
+ }, [statusItems]);
+
+ const handleItemClick = useCallback(
+ async (item: InboxItem) => {
+ if (!item.read) {
+ setMarkingAsReadId(item.id);
+ await markAsRead(item.id);
+ setMarkingAsReadId(null);
+ }
+
+ if (item.type === "new_mention") {
+ // Use type guard for safe metadata access
+ if (isNewMentionMetadata(item.metadata)) {
+ const searchSpaceId = item.search_space_id;
+ const threadId = item.metadata.thread_id;
+ const commentId = item.metadata.comment_id;
+
+ if (searchSpaceId && threadId) {
+ const url = commentId
+ ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
+ : `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
+ onOpenChange(false);
+ onCloseMobileSidebar?.();
+ router.push(url);
+ }
+ }
+ }
+ },
+ [markAsRead, router, onOpenChange, onCloseMobileSidebar]
+ );
+
+ const handleMarkAllAsRead = useCallback(async () => {
+ await markAllAsRead();
+ }, [markAllAsRead]);
+
+ const handleClearSearch = useCallback(() => {
+ setSearchQuery("");
+ }, []);
+
+ const formatTime = (dateString: string) => {
+ try {
+ const date = new Date(dateString);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffMins = Math.floor(diffMs / (1000 * 60));
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+ if (diffMins < 1) return "now";
+ if (diffMins < 60) return `${diffMins}m`;
+ if (diffHours < 24) return `${diffHours}h`;
+ if (diffDays < 7) return `${diffDays}d`;
+ return `${Math.floor(diffDays / 7)}w`;
+ } catch {
+ return "now";
+ }
+ };
+
+ const getStatusIcon = (item: InboxItem) => {
+ // For mentions, show the author's avatar with initials fallback
+ if (item.type === "new_mention") {
+ // Use type guard for safe metadata access
+ if (isNewMentionMetadata(item.metadata)) {
+ const authorName = item.metadata.author_name;
+ const avatarUrl = item.metadata.author_avatar_url;
+ const authorEmail = item.metadata.author_email;
+
+ return (
+
+ {avatarUrl && }
+
+ {getInitials(authorName, authorEmail)}
+
+
+ );
+ }
+ // Fallback for invalid metadata
+ return (
+
+
+ {getInitials(null, null)}
+
+
+ );
+ }
+
+ // For status items (connector/document), show status icons
+ // Safely access status from metadata
+ const metadata = item.metadata as Record;
+ const status = typeof metadata?.status === "string" ? metadata.status : undefined;
+
+ switch (status) {
+ case "in_progress":
+ return (
+
+
+
+ );
+ case "completed":
+ return (
+
+
+
+ );
+ case "failed":
+ return (
+
+ );
+ default:
+ return (
+
+
+
+ );
+ }
+ };
+
+ const getEmptyStateMessage = () => {
+ if (activeTab === "mentions") {
+ return {
+ title: t("no_mentions") || "No mentions",
+ hint: t("no_mentions_hint") || "You'll see mentions from others here",
+ };
+ }
+ return {
+ title: t("no_status_updates") || "No status updates",
+ hint: t("no_status_updates_hint") || "Document and connector updates will appear here",
+ };
+ };
+
+ if (!mounted) return null;
+
+ return createPortal(
+
+ {open && (
+ <>
+ onOpenChange(false)}
+ aria-hidden="true"
+ />
+
+
+
+
+
+
+
{t("inbox") || "Inbox"}
+
+
+ {/* Mobile: Button that opens bottom drawer */}
+ {isMobile ? (
+ <>
+
+
+ setFilterDrawerOpen(true)}
+ >
+
+ {t("filter") || "Filter"}
+
+
+ {t("filter") || "Filter"}
+
+
+
+
+
+
+
+ {t("filter") || "Filter"}
+
+
+
+ {/* Filter section */}
+
+
+ {t("filter") || "Filter"}
+
+
+ {
+ setActiveFilter("all");
+ setFilterDrawerOpen(false);
+ }}
+ className={cn(
+ "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
+ activeFilter === "all"
+ ? "bg-primary/10 text-primary"
+ : "hover:bg-muted"
+ )}
+ >
+
+
+ {t("all") || "All"}
+
+ {activeFilter === "all" && }
+
+ {
+ setActiveFilter("unread");
+ setFilterDrawerOpen(false);
+ }}
+ className={cn(
+ "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
+ activeFilter === "unread"
+ ? "bg-primary/10 text-primary"
+ : "hover:bg-muted"
+ )}
+ >
+
+
+ {t("unread") || "Unread"}
+
+ {activeFilter === "unread" && }
+
+
+
+ {/* Connectors section - only for status tab */}
+ {activeTab === "status" && uniqueConnectorTypes.length > 0 && (
+
+
+ {t("connectors") || "Connectors"}
+
+
+ {
+ setSelectedConnector(null);
+ setFilterDrawerOpen(false);
+ }}
+ className={cn(
+ "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
+ selectedConnector === null
+ ? "bg-primary/10 text-primary"
+ : "hover:bg-muted"
+ )}
+ >
+
+
+ {t("all_connectors") || "All connectors"}
+
+ {selectedConnector === null && }
+
+ {uniqueConnectorTypes.map((connector) => (
+ {
+ setSelectedConnector(connector.type);
+ setFilterDrawerOpen(false);
+ }}
+ className={cn(
+ "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
+ selectedConnector === connector.type
+ ? "bg-primary/10 text-primary"
+ : "hover:bg-muted"
+ )}
+ >
+
+ {getConnectorIcon(connector.type, "h-4 w-4")}
+ {connector.displayName}
+
+ {selectedConnector === connector.type && (
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+
+ >
+ ) : (
+ /* Desktop: Dropdown menu */
+
setOpenDropdown(isOpen ? "filter" : null)}
+ >
+
+
+
+
+
+ {t("filter") || "Filter"}
+
+
+
+ {t("filter") || "Filter"}
+
+
+
+ {t("filter") || "Filter"}
+
+ setActiveFilter("all")}
+ className="flex items-center justify-between"
+ >
+
+
+ {t("all") || "All"}
+
+ {activeFilter === "all" && }
+
+ setActiveFilter("unread")}
+ className="flex items-center justify-between"
+ >
+
+
+ {t("unread") || "Unread"}
+
+ {activeFilter === "unread" && }
+
+ {activeTab === "status" && uniqueConnectorTypes.length > 0 && (
+ <>
+
+ {t("connectors") || "Connectors"}
+
+ setSelectedConnector(null)}
+ className="flex items-center justify-between"
+ >
+
+
+ {t("all_connectors") || "All connectors"}
+
+ {selectedConnector === null && }
+
+ {uniqueConnectorTypes.map((connector) => (
+ setSelectedConnector(connector.type)}
+ className="flex items-center justify-between"
+ >
+
+ {getConnectorIcon(connector.type, "h-4 w-4")}
+ {connector.displayName}
+
+ {selectedConnector === connector.type && (
+
+ )}
+
+ ))}
+ >
+ )}
+
+
+ )}
+
+
+
+
+ {t("mark_all_read") || "Mark all as read"}
+
+
+
+ {t("mark_all_read") || "Mark all as read"}
+
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-9 pr-8 h-9"
+ />
+ {searchQuery && (
+
+
+ {t("clear_search") || "Clear search"}
+
+ )}
+
+
+
+ setActiveTab(value as InboxTab)}
+ className="shrink-0 mx-4"
+ >
+
+
+
+
+ {t("mentions") || "Mentions"}
+
+ {unreadMentionsCount}
+
+
+
+
+
+
+ {t("status") || "Status"}
+
+ {unreadStatusCount}
+
+
+
+
+
+
+
+ {loading ? (
+
+
+
+ ) : filteredItems.length > 0 ? (
+
+ {filteredItems.map((item, index) => {
+ const isMarkingAsRead = markingAsReadId === item.id;
+ // Place prefetch trigger on 5th item from end (only if not searching)
+ const isPrefetchTrigger =
+ !searchQuery && hasMore && index === filteredItems.length - 5;
+
+ return (
+
+
+
+ handleItemClick(item)}
+ disabled={isMarkingAsRead}
+ className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
+ >
+ {getStatusIcon(item)}
+
+
+ {item.title}
+
+
+ {convertRenderedToDisplay(item.message)}
+
+
+
+
+
+ {item.title}
+
+ {convertRenderedToDisplay(item.message)}
+
+
+
+
+ {/* Time and unread dot - fixed width to prevent content shift */}
+
+
+ {formatTime(item.created_at)}
+
+ {!item.read && (
+
+ )}
+
+
+ );
+ })}
+ {/* Fallback trigger at the very end if less than 5 items and not searching */}
+ {!searchQuery && filteredItems.length < 5 && hasMore && (
+
+ )}
+
+ ) : searchQuery ? (
+
+
+
+ {t("no_results_found") || "No results found"}
+
+
+ {t("try_different_search") || "Try a different search term"}
+
+
+ ) : (
+
+ {activeTab === "mentions" ? (
+
+ ) : (
+
+ )}
+
{getEmptyStateMessage().title}
+
+ {getEmptyStateMessage().hint}
+
+
+ )}
+
+
+ >
+ )}
+ ,
+ document.body
+ );
+}
diff --git a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx
index 7b694055b..d2d926de8 100644
--- a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx
@@ -30,7 +30,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
type="button"
onClick={() => onItemClick?.(item)}
className={cn(
- "flex h-10 w-10 items-center justify-center rounded-md transition-colors",
+ "relative flex h-10 w-10 items-center justify-center rounded-md transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
item.isActive && "bg-accent text-accent-foreground"
@@ -38,6 +38,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
{...joyrideAttr}
>
+ {item.badge && (
+
+ {item.badge}
+
+ )}
{item.title}
@@ -64,7 +69,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
>
{item.title}
- {item.badge && {item.badge} }
+ {item.badge && (
+
+ {item.badge}
+
+ )}
);
})}
diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
index 336b695ea..f3452749f 100644
--- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
@@ -119,11 +119,6 @@ export function Sidebar({
)}
- {/* Platform navigation */}
- {navItems.length > 0 && (
-
- )}
-
{/* Scrollable content */}
{isCollapsed ? (
@@ -235,7 +230,12 @@ export function Sidebar({
{/* Footer */}
-
+
+ {/* Platform navigation */}
+ {navItems.length > 0 && (
+
+ )}
+
{pageUsage && !isCollapsed && (
)}
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx
index 6ca057819..5c8c89152 100644
--- a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx
@@ -1,6 +1,7 @@
"use client";
-import { ChevronsUpDown, Settings, Users } from "lucide-react";
+import { ChevronsUpDown, ScrollText, Settings, Users } from "lucide-react";
+import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
@@ -29,6 +30,9 @@ export function SidebarHeader({
className,
}: SidebarHeaderProps) {
const t = useTranslations("sidebar");
+ const router = useRouter();
+ const params = useParams();
+ const searchSpaceId = params.search_space_id as string;
return (
@@ -52,6 +56,10 @@ export function SidebarHeader({
{t("manage_members")}
+
router.push(`/dashboard/${searchSpaceId}/logs`)}>
+
+ {t("logs")}
+
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx
index 52d681199..0ceafc113 100644
--- a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx
@@ -37,7 +37,7 @@ export function SidebarSection({
{/* Action button - visible on hover (always visible on mobile) */}
{action && (
-
+
{action}
)}
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
index 907fd89f8..982d88e8b 100644
--- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ChevronUp, Laptop, Languages, LogOut, Moon, Settings, Sun } from "lucide-react";
+import { ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
import { useTranslations } from "next-intl";
import {
DropdownMenu,
diff --git a/surfsense_web/components/layout/ui/sidebar/index.ts b/surfsense_web/components/layout/ui/sidebar/index.ts
index 282e4740b..d9c5edee5 100644
--- a/surfsense_web/components/layout/ui/sidebar/index.ts
+++ b/surfsense_web/components/layout/ui/sidebar/index.ts
@@ -1,6 +1,7 @@
export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
export { ChatListItem } from "./ChatListItem";
+export { InboxSidebar } from "./InboxSidebar";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { NavSection } from "./NavSection";
export { PageUsageDisplay } from "./PageUsageDisplay";
diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx
index 05a8a7306..fcace2572 100644
--- a/surfsense_web/components/new-chat/chat-share-button.tsx
+++ b/surfsense_web/components/new-chat/chat-share-button.tsx
@@ -2,7 +2,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
-import { Loader2, User, Users } from "lucide-react";
+import { User, Users } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
@@ -45,7 +45,6 @@ const visibilityOptions: {
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
- const [isUpdating, setIsUpdating] = useState(false);
// Use Jotai atom for visibility (single source of truth)
const currentThreadState = useAtomValue(currentThreadAtom);
@@ -62,7 +61,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
return;
}
- setIsUpdating(true);
// Update Jotai atom immediately for instant UI feedback
setThreadVisibility(newVisibility);
@@ -84,8 +82,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
// Revert Jotai state on error
setThreadVisibility(thread.visibility ?? "PRIVATE");
toast.error("Failed to update sharing settings");
- } finally {
- setIsUpdating(false);
}
},
[thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility]
@@ -128,16 +124,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
onCloseAutoFocus={(e) => e.preventDefault()}
>
- {/* Updating overlay */}
- {isUpdating && (
-
- )}
-
{visibilityOptions.map((option) => {
const isSelected = currentVisibility === option.value;
const Icon = option.icon;
@@ -147,7 +133,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
type="button"
key={option.value}
onClick={() => handleVisibilityChange(option.value)}
- disabled={isUpdating}
className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer",
diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx
index fba5e8cb1..bc7591c51 100644
--- a/surfsense_web/components/new-chat/model-selector.tsx
+++ b/surfsense_web/components/new-chat/model-selector.tsx
@@ -72,7 +72,6 @@ interface ModelSelectorProps {
export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
- const [isSwitching, setIsSwitching] = useState(false);
// Fetch configs
const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom);
@@ -124,6 +123,11 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
);
}, [userConfigs, searchQuery]);
+ // Total model count for conditional search display
+ const totalModels = useMemo(() => {
+ return (globalConfigs?.length ?? 0) + (userConfigs?.length ?? 0);
+ }, [globalConfigs, userConfigs]);
+
const handleSelectConfig = useCallback(
async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => {
// If already selected, just close
@@ -137,7 +141,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
return;
}
- setIsSwitching(true);
try {
await updatePreferences({
search_space_id: Number(searchSpaceId),
@@ -150,8 +153,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
} catch (error) {
console.error("Failed to switch model:", error);
toast.error("Failed to switch model");
- } finally {
- setIsSwitching(false);
}
},
[currentConfig, searchSpaceId, updatePreferences]
@@ -216,26 +217,17 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
shouldFilter={false}
className="rounded-lg relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
>
- {/* Switching overlay */}
- {isSwitching && (
-
-
-
- Switching model...
-
+ {totalModels > 3 && (
+
+
)}
-
-
-
-
@@ -260,7 +252,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
value={`global-${config.id}`}
onSelect={() => handleSelectConfig(config)}
className={cn(
- "mx-2 rounded-lg mb-1 cursor-pointer transition-all",
+ "mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
)}
@@ -291,7 +283,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
handleEditConfig(e, config, true)}
>
@@ -322,7 +314,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
value={`user-${config.id}`}
onSelect={() => handleSelectConfig(config)}
className={cn(
- "mx-2 rounded-lg mb-1 cursor-pointer transition-all",
+ "mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
)}
@@ -353,7 +345,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
handleEditConfig(e, config, false)}
>
diff --git a/surfsense_web/components/notifications/NotificationButton.tsx b/surfsense_web/components/notifications/NotificationButton.tsx
deleted file mode 100644
index 020fea506..000000000
--- a/surfsense_web/components/notifications/NotificationButton.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-"use client";
-
-import { useAtomValue } from "jotai";
-import { Bell } from "lucide-react";
-import { useParams } from "next/navigation";
-import { useCallback, useEffect, useState } from "react";
-import { currentUserAtom } from "@/atoms/user/user-query.atoms";
-import { Button } from "@/components/ui/button";
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { useNotifications, type NotificationTypeEnum } from "@/hooks/use-notifications";
-import { cn } from "@/lib/utils";
-import { NotificationPopup } from "./NotificationPopup";
-
-const NOTIFICATION_FILTER_STORAGE_KEY = "surfsense_notification_filter";
-
-export function NotificationButton() {
- const [open, setOpen] = useState(false);
- const { data: user } = useAtomValue(currentUserAtom);
- const params = useParams();
-
- // Filter state - null means show all, otherwise filter by type
- const [activeFilter, setActiveFilter] = useState(null);
-
- // Load filter from localStorage on mount
- useEffect(() => {
- try {
- const stored = localStorage.getItem(NOTIFICATION_FILTER_STORAGE_KEY);
- if (stored) {
- const parsed = JSON.parse(stored);
- if (
- parsed === null ||
- ["new_mention", "connector_indexing", "document_processing"].includes(parsed)
- ) {
- setActiveFilter(parsed);
- }
- }
- } catch {
- // Ignore localStorage errors
- }
- }, []);
-
- // Handle filter toggle - clicking same pill again shows all
- const handleFilterChange = useCallback((filter: NotificationTypeEnum | null) => {
- setActiveFilter((current) => {
- const newFilter = current === filter ? null : filter;
- try {
- localStorage.setItem(NOTIFICATION_FILTER_STORAGE_KEY, JSON.stringify(newFilter));
- } catch {
- // Ignore localStorage errors
- }
- return newFilter;
- });
- }, []);
-
- const userId = user?.id ? String(user.id) : null;
- // Get searchSpaceId from URL params - the component is rendered within /dashboard/[search_space_id]/
- const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
-
- const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = useNotifications(
- userId,
- searchSpaceId,
- activeFilter
- );
-
- return (
-
-
-
-
-
-
- {unreadCount > 0 && (
- 9 && "px-1"
- )}
- >
- {unreadCount > 99 ? "99+" : unreadCount}
-
- )}
- Notifications
-
-
-
- Notifications
-
-
- setOpen(false)}
- activeFilter={activeFilter}
- onFilterChange={handleFilterChange}
- />
-
-
- );
-}
diff --git a/surfsense_web/components/notifications/NotificationPopup.tsx b/surfsense_web/components/notifications/NotificationPopup.tsx
deleted file mode 100644
index fbb756a00..000000000
--- a/surfsense_web/components/notifications/NotificationPopup.tsx
+++ /dev/null
@@ -1,246 +0,0 @@
-"use client";
-
-import { formatDistanceToNow } from "date-fns";
-import {
- AlertCircle,
- AtSign,
- Bell,
- Cable,
- CheckCheck,
- CheckCircle2,
- FileText,
- Loader2,
-} from "lucide-react";
-import { useRouter } from "next/navigation";
-import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { Button } from "@/components/ui/button";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Separator } from "@/components/ui/separator";
-import type { Notification, NotificationTypeEnum } from "@/hooks/use-notifications";
-import { cn } from "@/lib/utils";
-
-/**
- * Filter configuration for notification types
- */
-const NOTIFICATION_FILTERS = {
- new_mention: { label: "Mentions", icon: AtSign },
- connector_indexing: { label: "Connectors", icon: Cable },
- document_processing: { label: "Documents", icon: FileText },
-} as const;
-
-/**
- * Get initials from name or email for avatar fallback
- */
-function getInitials(name: string | null | undefined, email: string | null | undefined): string {
- if (name) {
- return name
- .split(" ")
- .map((n) => n[0])
- .join("")
- .toUpperCase()
- .slice(0, 2);
- }
- if (email) {
- const localPart = email.split("@")[0];
- return localPart.slice(0, 2).toUpperCase();
- }
- return "U";
-}
-
-interface NotificationPopupProps {
- notifications: Notification[];
- unreadCount: number;
- loading: boolean;
- markAsRead: (id: number) => Promise;
- markAllAsRead: () => Promise;
- onClose?: () => void;
- activeFilter: NotificationTypeEnum | null;
- onFilterChange: (filter: NotificationTypeEnum | null) => void;
-}
-
-export function NotificationPopup({
- notifications,
- unreadCount,
- loading,
- markAsRead,
- markAllAsRead,
- onClose,
- activeFilter,
- onFilterChange,
-}: NotificationPopupProps) {
- const router = useRouter();
-
- const handleMarkAllAsRead = async () => {
- await markAllAsRead();
- };
-
- const handleNotificationClick = async (notification: Notification) => {
- if (!notification.read) {
- await markAsRead(notification.id);
- }
-
- if (notification.type === "new_mention") {
- const metadata = notification.metadata as {
- thread_id?: number;
- comment_id?: number;
- };
- const searchSpaceId = notification.search_space_id;
- const threadId = metadata?.thread_id;
- const commentId = metadata?.comment_id;
-
- if (searchSpaceId && threadId) {
- const url = commentId
- ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
- : `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
- onClose?.();
- router.push(url);
- }
- }
- };
-
- const formatTime = (dateString: string) => {
- try {
- return formatDistanceToNow(new Date(dateString), { addSuffix: true });
- } catch {
- return "Recently";
- }
- };
-
- const getStatusIcon = (notification: Notification) => {
- // For mentions, show the author's avatar with initials fallback
- if (notification.type === "new_mention") {
- const metadata = notification.metadata as {
- author_name?: string;
- author_avatar_url?: string | null;
- author_email?: string;
- };
- const authorName = metadata?.author_name;
- const avatarUrl = metadata?.author_avatar_url;
- const authorEmail = metadata?.author_email;
-
- return (
-
- {avatarUrl && }
-
- {getInitials(authorName, authorEmail)}
-
-
- );
- }
-
- // For other notification types, show status icons
- const status = notification.metadata?.status as string | undefined;
-
- switch (status) {
- case "in_progress":
- return ;
- case "completed":
- return ;
- case "failed":
- return ;
- default:
- return ;
- }
- };
-
- return (
-
- {/* Header */}
-
-
-
Notifications
-
- {unreadCount > 0 && (
-
-
- Mark all read
-
- )}
-
-
- {/* Filter Pills */}
-
- {(
- Object.entries(NOTIFICATION_FILTERS) as [
- NotificationTypeEnum,
- (typeof NOTIFICATION_FILTERS)[keyof typeof NOTIFICATION_FILTERS],
- ][]
- ).map(([key, { label, icon: Icon }]) => {
- const isActive = activeFilter === key;
- return (
- onFilterChange(key)}
- className={cn(
- "inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-medium transition-colors whitespace-nowrap",
- "border focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
- isActive
- ? "bg-primary text-primary-foreground border-primary"
- : "bg-transparent text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground"
- )}
- >
-
- {label}
-
- );
- })}
-
-
- {/* Notifications List */}
-
- {loading ? (
-
-
-
- ) : notifications.length === 0 ? (
-
- ) : (
-
- {notifications.map((notification, index) => (
-
-
handleNotificationClick(notification)}
- className={cn(
- "w-full px-4 py-3 text-left hover:bg-accent transition-colors",
- !notification.read && "bg-accent/50"
- )}
- >
-
-
{getStatusIcon(notification)}
-
-
-
- {notification.title}
-
-
-
- {convertRenderedToDisplay(notification.message)}
-
-
-
- {formatTime(notification.created_at)}
-
-
-
-
-
- {index < notifications.length - 1 &&
}
-
- ))}
-
- )}
-
-
- );
-}
diff --git a/surfsense_web/components/shared/llm-config-form.tsx b/surfsense_web/components/shared/llm-config-form.tsx
index e2e45194b..5ffff1ab7 100644
--- a/surfsense_web/components/shared/llm-config-form.tsx
+++ b/surfsense_web/components/shared/llm-config-form.tsx
@@ -551,7 +551,9 @@ export function LLMConfigForm({
render={({ field }) => (
-
Enable Citations
+
+ Enable Citations
+
Include [citation:id] references to source documents
diff --git a/surfsense_web/components/ui/drawer.tsx b/surfsense_web/components/ui/drawer.tsx
new file mode 100644
index 000000000..c92531d14
--- /dev/null
+++ b/surfsense_web/components/ui/drawer.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import type * as React from "react";
+import { Drawer as DrawerPrimitive } from "vaul";
+
+import { cn } from "@/lib/utils";
+
+function Drawer({
+ shouldScaleBackground = true,
+ ...props
+}: React.ComponentProps
) {
+ return ;
+}
+Drawer.displayName = "Drawer";
+
+const DrawerTrigger = DrawerPrimitive.Trigger;
+
+const DrawerPortal = DrawerPrimitive.Portal;
+
+const DrawerClose = DrawerPrimitive.Close;
+
+function DrawerOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
+
+function DrawerContent({
+ className,
+ children,
+ overlayClassName,
+ ...props
+}: React.ComponentProps & {
+ overlayClassName?: string;
+}) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
+DrawerContent.displayName = "DrawerContent";
+
+function DrawerHeader({ className, ...props }: React.HTMLAttributes) {
+ return
;
+}
+DrawerHeader.displayName = "DrawerHeader";
+
+function DrawerFooter({ className, ...props }: React.HTMLAttributes) {
+ return
;
+}
+DrawerFooter.displayName = "DrawerFooter";
+
+function DrawerTitle({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
+
+function DrawerDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
+
+function DrawerHandle({ className, ...props }: React.HTMLAttributes) {
+ return (
+
+ );
+}
+DrawerHandle.displayName = "DrawerHandle";
+
+export {
+ Drawer,
+ DrawerPortal,
+ DrawerOverlay,
+ DrawerTrigger,
+ DrawerClose,
+ DrawerContent,
+ DrawerHeader,
+ DrawerFooter,
+ DrawerTitle,
+ DrawerDescription,
+ DrawerHandle,
+};
diff --git a/surfsense_web/components/ui/sheet.tsx b/surfsense_web/components/ui/sheet.tsx
index accd4f782..650e85403 100644
--- a/surfsense_web/components/ui/sheet.tsx
+++ b/surfsense_web/components/ui/sheet.tsx
@@ -42,13 +42,15 @@ function SheetContent({
className,
children,
side = "right",
+ overlayClassName,
...props
}: React.ComponentProps & {
side?: "top" | "right" | "bottom" | "left";
+ overlayClassName?: string;
}) {
return (
-
+
+ );
+}
diff --git a/surfsense_web/content/docs/how-to/electric-sql.mdx b/surfsense_web/content/docs/how-to/electric-sql.mdx
index 54244c19b..288745850 100644
--- a/surfsense_web/content/docs/how-to/electric-sql.mdx
+++ b/surfsense_web/content/docs/how-to/electric-sql.mdx
@@ -5,11 +5,11 @@ description: Setting up Electric SQL for real-time data synchronization in SurfS
# Electric SQL
-[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for notifications, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL.
+[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for inbox items, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL.
## What Does Electric SQL Do?
-When you index documents or receive notifications, Electric SQL pushes updates to your browser in real-time. The data flows like this:
+When you index documents or receive inbox updates, Electric SQL pushes updates to your browser in real-time. The data flows like this:
1. Backend writes data to PostgreSQL
2. Electric SQL detects changes and streams them to the frontend
@@ -18,7 +18,7 @@ When you index documents or receive notifications, Electric SQL pushes updates t
This means:
-- **Notifications appear instantly** - No need to refresh the page
+- **Inbox updates appear instantly** - No need to refresh the page
- **Document indexing progress updates live** - Watch your documents get processed
- **Connector status syncs automatically** - See when connectors finish syncing
- **Offline support** - PGlite caches data locally, so previously loaded data remains accessible
diff --git a/surfsense_web/contracts/types/chat-comments.types.ts b/surfsense_web/contracts/types/chat-comments.types.ts
index 92b3ff060..46e064a4e 100644
--- a/surfsense_web/contracts/types/chat-comments.types.ts
+++ b/surfsense_web/contracts/types/chat-comments.types.ts
@@ -1,5 +1,19 @@
import { z } from "zod";
+/**
+ * Raw comment
+ */
+export const rawComment = z.object({
+ id: z.number(),
+ message_id: z.number(),
+ thread_id: z.number(), // Denormalized for efficient Electric subscriptions
+ parent_id: z.number().nullable(),
+ author_id: z.string().nullable(),
+ content: z.string(),
+ created_at: z.string(),
+ updated_at: z.string(),
+});
+
export const author = z.object({
id: z.string().uuid(),
display_name: z.string().nullable(),
@@ -122,6 +136,7 @@ export const getMentionsResponse = z.object({
total_count: z.number(),
});
+export type RawComment = z.infer;
export type Author = z.infer;
export type CommentReply = z.infer;
export type Comment = z.infer;
diff --git a/surfsense_web/contracts/types/chat-messages.types.ts b/surfsense_web/contracts/types/chat-messages.types.ts
new file mode 100644
index 000000000..78bf7b043
--- /dev/null
+++ b/surfsense_web/contracts/types/chat-messages.types.ts
@@ -0,0 +1,15 @@
+import { z } from "zod";
+
+/**
+ * Raw message from database (Electric SQL sync)
+ */
+export const rawMessage = z.object({
+ id: z.number(),
+ thread_id: z.number(),
+ role: z.string(),
+ content: z.unknown(),
+ author_id: z.string().nullable(),
+ created_at: z.string(),
+});
+
+export type RawMessage = z.infer;
diff --git a/surfsense_web/contracts/types/chat-session-state.types.ts b/surfsense_web/contracts/types/chat-session-state.types.ts
new file mode 100644
index 000000000..cf73859e6
--- /dev/null
+++ b/surfsense_web/contracts/types/chat-session-state.types.ts
@@ -0,0 +1,24 @@
+import { z } from "zod";
+
+/**
+ * Chat session state for live collaboration.
+ * Tracks which user the AI is currently responding to.
+ */
+export const chatSessionState = z.object({
+ id: z.number(),
+ thread_id: z.number(),
+ ai_responding_to_user_id: z.string().uuid().nullable(),
+ updated_at: z.string(),
+});
+
+/**
+ * User currently being responded to by the AI.
+ */
+export const respondingUser = z.object({
+ id: z.string().uuid(),
+ display_name: z.string().nullable(),
+ email: z.string(),
+});
+
+export type ChatSessionState = z.infer;
+export type RespondingUser = z.infer;
diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts
new file mode 100644
index 000000000..0983bbc55
--- /dev/null
+++ b/surfsense_web/contracts/types/inbox.types.ts
@@ -0,0 +1,281 @@
+import { z } from "zod";
+import { searchSourceConnectorTypeEnum } from "./connector.types";
+import { documentTypeEnum } from "./document.types";
+
+/**
+ * Inbox item type enum - matches backend notification types
+ */
+export const inboxItemTypeEnum = z.enum([
+ "connector_indexing",
+ "document_processing",
+ "new_mention",
+]);
+
+/**
+ * Inbox item status enum - used in metadata
+ */
+export const inboxItemStatusEnum = z.enum(["in_progress", "completed", "failed"]);
+
+/**
+ * Document processing stage enum
+ */
+export const documentProcessingStageEnum = z.enum([
+ "queued",
+ "parsing",
+ "chunking",
+ "embedding",
+ "storing",
+ "completed",
+ "failed",
+]);
+
+/**
+ * Base metadata schema shared across inbox item types
+ */
+export const baseInboxItemMetadata = z.object({
+ operation_id: z.string().optional(),
+ status: inboxItemStatusEnum.optional(),
+ started_at: z.string().optional(),
+ completed_at: z.string().optional(),
+});
+
+/**
+ * Connector indexing metadata schema
+ */
+export const connectorIndexingMetadata = baseInboxItemMetadata.extend({
+ connector_id: z.number(),
+ connector_name: z.string(),
+ connector_type: searchSourceConnectorTypeEnum,
+ start_date: z.string().nullable().optional(),
+ end_date: z.string().nullable().optional(),
+ indexed_count: z.number(),
+ total_count: z.number().optional(),
+ progress_percent: z.number().optional(),
+ error_message: z.string().nullable().optional(),
+ // Google Drive specific fields
+ folder_count: z.number().optional(),
+ file_count: z.number().optional(),
+ folder_names: z.array(z.string()).optional(),
+ file_names: z.array(z.string()).optional(),
+});
+
+/**
+ * Document processing metadata schema
+ */
+export const documentProcessingMetadata = baseInboxItemMetadata.extend({
+ document_type: documentTypeEnum,
+ document_name: z.string(),
+ processing_stage: documentProcessingStageEnum,
+ file_size: z.number().optional(),
+ chunks_count: z.number().optional(),
+ document_id: z.number().optional(),
+ error_message: z.string().nullable().optional(),
+});
+
+/**
+ * New mention metadata schema
+ */
+export const newMentionMetadata = z.object({
+ mention_id: z.number(),
+ comment_id: z.number(),
+ message_id: z.number(),
+ thread_id: z.number(),
+ thread_title: z.string(),
+ author_id: z.string(),
+ author_name: z.string(),
+ author_avatar_url: z.string().nullable().optional(),
+ author_email: z.string().optional(),
+ content_preview: z.string(),
+});
+
+/**
+ * Union of all inbox item metadata types
+ * Use this when the inbox item type is unknown
+ */
+export const inboxItemMetadata = z.union([
+ connectorIndexingMetadata,
+ documentProcessingMetadata,
+ newMentionMetadata,
+ baseInboxItemMetadata,
+]);
+
+/**
+ * Main inbox item schema
+ */
+export const inboxItem = z.object({
+ id: z.number(),
+ user_id: z.string(),
+ search_space_id: z.number().nullable(),
+ type: inboxItemTypeEnum,
+ title: z.string(),
+ message: z.string(),
+ read: z.boolean(),
+ metadata: z.record(z.string(), z.unknown()),
+ created_at: z.string(),
+ updated_at: z.string().nullable(),
+});
+
+/**
+ * Typed inbox item schemas for specific types
+ */
+export const connectorIndexingInboxItem = inboxItem.extend({
+ type: z.literal("connector_indexing"),
+ metadata: connectorIndexingMetadata,
+});
+
+export const documentProcessingInboxItem = inboxItem.extend({
+ type: z.literal("document_processing"),
+ metadata: documentProcessingMetadata,
+});
+
+export const newMentionInboxItem = inboxItem.extend({
+ type: z.literal("new_mention"),
+ metadata: newMentionMetadata,
+});
+
+// =============================================================================
+// API Request/Response Schemas
+// =============================================================================
+
+/**
+ * Request schema for getting notifications
+ */
+export const getNotificationsRequest = z.object({
+ queryParams: z.object({
+ search_space_id: z.number().optional(),
+ type: inboxItemTypeEnum.optional(),
+ before_date: z.string().optional(),
+ limit: z.number().min(1).max(100).optional(),
+ offset: z.number().min(0).optional(),
+ }),
+});
+
+/**
+ * Response schema for listing notifications
+ */
+export const getNotificationsResponse = z.object({
+ items: z.array(inboxItem),
+ total: z.number(),
+ has_more: z.boolean(),
+ next_offset: z.number().nullable(),
+});
+
+/**
+ * Request schema for marking a single notification as read
+ */
+export const markNotificationReadRequest = z.object({
+ notificationId: z.number(),
+});
+
+/**
+ * Response schema for mark as read operations
+ */
+export const markNotificationReadResponse = z.object({
+ success: z.boolean(),
+ message: z.string(),
+});
+
+/**
+ * Response schema for mark all as read operation
+ */
+export const markAllNotificationsReadResponse = z.object({
+ success: z.boolean(),
+ message: z.string(),
+ updated_count: z.number(),
+});
+
+/**
+ * Request schema for getting unread count
+ */
+export const getUnreadCountRequest = z.object({
+ search_space_id: z.number().optional(),
+});
+
+/**
+ * Response schema for unread count
+ * Returns both total and recent counts for split tracking
+ */
+export const getUnreadCountResponse = z.object({
+ total_unread: z.number(),
+ recent_unread: z.number(), // Within SYNC_WINDOW_DAYS (14 days)
+});
+
+// =============================================================================
+// Type Guards for Metadata
+// =============================================================================
+
+/**
+ * Type guard for ConnectorIndexingMetadata
+ */
+export function isConnectorIndexingMetadata(
+ metadata: unknown
+): metadata is ConnectorIndexingMetadata {
+ return connectorIndexingMetadata.safeParse(metadata).success;
+}
+
+/**
+ * Type guard for DocumentProcessingMetadata
+ */
+export function isDocumentProcessingMetadata(
+ metadata: unknown
+): metadata is DocumentProcessingMetadata {
+ return documentProcessingMetadata.safeParse(metadata).success;
+}
+
+/**
+ * Type guard for NewMentionMetadata
+ */
+export function isNewMentionMetadata(metadata: unknown): metadata is NewMentionMetadata {
+ return newMentionMetadata.safeParse(metadata).success;
+}
+
+/**
+ * Safe metadata parser - returns typed metadata or null
+ */
+export function parseInboxItemMetadata(
+ type: InboxItemTypeEnum,
+ metadata: unknown
+): ConnectorIndexingMetadata | DocumentProcessingMetadata | NewMentionMetadata | null {
+ switch (type) {
+ case "connector_indexing": {
+ const result = connectorIndexingMetadata.safeParse(metadata);
+ return result.success ? result.data : null;
+ }
+ case "document_processing": {
+ const result = documentProcessingMetadata.safeParse(metadata);
+ return result.success ? result.data : null;
+ }
+ case "new_mention": {
+ const result = newMentionMetadata.safeParse(metadata);
+ return result.success ? result.data : null;
+ }
+ default:
+ return null;
+ }
+}
+
+// =============================================================================
+// Inferred types
+// =============================================================================
+
+export type InboxItemTypeEnum = z.infer;
+export type InboxItemStatusEnum = z.infer;
+export type DocumentProcessingStageEnum = z.infer;
+export type BaseInboxItemMetadata = z.infer;
+export type ConnectorIndexingMetadata = z.infer;
+export type DocumentProcessingMetadata = z.infer;
+export type NewMentionMetadata = z.infer;
+export type InboxItemMetadata = z.infer;
+export type InboxItem = z.infer;
+export type ConnectorIndexingInboxItem = z.infer;
+export type DocumentProcessingInboxItem = z.infer;
+export type NewMentionInboxItem = z.infer;
+
+// API Request/Response types
+export type GetNotificationsRequest = z.infer;
+export type GetNotificationsResponse = z.infer;
+export type MarkNotificationReadRequest = z.infer;
+export type MarkNotificationReadResponse = z.infer;
+export type MarkAllNotificationsReadResponse = z.infer;
+export type GetUnreadCountRequest = z.infer;
+export type GetUnreadCountResponse = z.infer;
diff --git a/surfsense_web/contracts/types/notification.types.ts b/surfsense_web/contracts/types/notification.types.ts
deleted file mode 100644
index b2b39d26e..000000000
--- a/surfsense_web/contracts/types/notification.types.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-import { z } from "zod";
-import { searchSourceConnectorTypeEnum } from "./connector.types";
-import { documentTypeEnum } from "./document.types";
-
-/**
- * Notification type enum - matches backend notification types
- */
-export const notificationTypeEnum = z.enum([
- "connector_indexing",
- "document_processing",
- "new_mention",
-]);
-
-/**
- * Notification status enum - used in metadata
- */
-export const notificationStatusEnum = z.enum(["in_progress", "completed", "failed"]);
-
-/**
- * Document processing stage enum
- */
-export const documentProcessingStageEnum = z.enum([
- "queued",
- "parsing",
- "chunking",
- "embedding",
- "storing",
- "completed",
- "failed",
-]);
-
-/**
- * Base metadata schema shared across notification types
- */
-export const baseNotificationMetadata = z.object({
- operation_id: z.string().optional(),
- status: notificationStatusEnum.optional(),
- started_at: z.string().optional(),
- completed_at: z.string().optional(),
-});
-
-/**
- * Connector indexing metadata schema
- */
-export const connectorIndexingMetadata = baseNotificationMetadata.extend({
- connector_id: z.number(),
- connector_name: z.string(),
- connector_type: searchSourceConnectorTypeEnum,
- start_date: z.string().nullable().optional(),
- end_date: z.string().nullable().optional(),
- indexed_count: z.number(),
- total_count: z.number().optional(),
- progress_percent: z.number().optional(),
- error_message: z.string().nullable().optional(),
- // Google Drive specific fields
- folder_count: z.number().optional(),
- file_count: z.number().optional(),
- folder_names: z.array(z.string()).optional(),
- file_names: z.array(z.string()).optional(),
-});
-
-/**
- * Document processing metadata schema
- */
-export const documentProcessingMetadata = baseNotificationMetadata.extend({
- document_type: documentTypeEnum,
- document_name: z.string(),
- processing_stage: documentProcessingStageEnum,
- file_size: z.number().optional(),
- chunks_count: z.number().optional(),
- document_id: z.number().optional(),
- error_message: z.string().nullable().optional(),
-});
-
-/**
- * New mention metadata schema
- */
-export const newMentionMetadata = z.object({
- mention_id: z.number(),
- comment_id: z.number(),
- message_id: z.number(),
- thread_id: z.number(),
- thread_title: z.string(),
- author_id: z.string(),
- author_name: z.string(),
- author_avatar_url: z.string().nullable().optional(),
- author_email: z.string().optional(),
- content_preview: z.string(),
-});
-
-/**
- * Union of all notification metadata types
- * Use this when the notification type is unknown
- */
-export const notificationMetadata = z.union([
- connectorIndexingMetadata,
- documentProcessingMetadata,
- newMentionMetadata,
- baseNotificationMetadata,
-]);
-
-/**
- * Main notification schema
- */
-export const notification = z.object({
- id: z.number(),
- user_id: z.string(),
- search_space_id: z.number().nullable(),
- type: notificationTypeEnum,
- title: z.string(),
- message: z.string(),
- read: z.boolean(),
- metadata: z.record(z.string(), z.unknown()),
- created_at: z.string(),
- updated_at: z.string().nullable(),
-});
-
-/**
- * Typed notification schemas for specific notification types
- */
-export const connectorIndexingNotification = notification.extend({
- type: z.literal("connector_indexing"),
- metadata: connectorIndexingMetadata,
-});
-
-export const documentProcessingNotification = notification.extend({
- type: z.literal("document_processing"),
- metadata: documentProcessingMetadata,
-});
-
-export const newMentionNotification = notification.extend({
- type: z.literal("new_mention"),
- metadata: newMentionMetadata,
-});
-
-// Inferred types
-export type NotificationTypeEnum = z.infer;
-export type NotificationStatusEnum = z.infer;
-export type DocumentProcessingStageEnum = z.infer;
-export type BaseNotificationMetadata = z.infer;
-export type ConnectorIndexingMetadata = z.infer;
-export type DocumentProcessingMetadata = z.infer;
-export type NewMentionMetadata = z.infer;
-export type NotificationMetadata = z.infer;
-export type Notification = z.infer;
-export type ConnectorIndexingNotification = z.infer;
-export type DocumentProcessingNotification = z.infer;
-export type NewMentionNotification = z.infer;
diff --git a/surfsense_web/hooks/use-chat-session-state.ts b/surfsense_web/hooks/use-chat-session-state.ts
new file mode 100644
index 000000000..f3bdd7722
--- /dev/null
+++ b/surfsense_web/hooks/use-chat-session-state.ts
@@ -0,0 +1,39 @@
+"use client";
+
+import { useShape } from "@electric-sql/react";
+import { useSetAtom } from "jotai";
+import { useEffect } from "react";
+import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
+import type { ChatSessionState } from "@/contracts/types/chat-session-state.types";
+
+const ELECTRIC_URL = process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133";
+
+/**
+ * Syncs chat session state for a thread via Electric SQL.
+ * Call once per thread (in page.tsx). Updates global atom.
+ */
+export function useChatSessionStateSync(threadId: number | null) {
+ const setSessionState = useSetAtom(chatSessionStateAtom);
+
+ const { data } = useShape({
+ url: `${ELECTRIC_URL}/v1/shape`,
+ params: {
+ table: "chat_session_state",
+ where: `thread_id = ${threadId ?? -1}`,
+ },
+ });
+
+ useEffect(() => {
+ if (!threadId) {
+ setSessionState(null);
+ return;
+ }
+
+ const row = data?.[0];
+ setSessionState({
+ threadId,
+ isAiResponding: !!row?.ai_responding_to_user_id,
+ respondingToUserId: row?.ai_responding_to_user_id ?? null,
+ });
+ }, [threadId, data, setSessionState]);
+}
diff --git a/surfsense_web/hooks/use-comments-electric.ts b/surfsense_web/hooks/use-comments-electric.ts
new file mode 100644
index 000000000..b90f52dce
--- /dev/null
+++ b/surfsense_web/hooks/use-comments-electric.ts
@@ -0,0 +1,405 @@
+"use client";
+
+import { useQueryClient } from "@tanstack/react-query";
+import { useAtomValue } from "jotai";
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
+import { currentUserAtom } from "@/atoms/user/user-query.atoms";
+import type { Author, Comment, CommentReply } from "@/contracts/types/chat-comments.types";
+import type { Membership } from "@/contracts/types/members.types";
+import type { SyncHandle } from "@/lib/electric/client";
+import { useElectricClient } from "@/lib/electric/context";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
+
+// Debounce delay for stream updates (ms)
+const STREAM_UPDATE_DEBOUNCE_MS = 100;
+
+// Raw comment from PGlite local database
+interface RawCommentRow {
+ id: number;
+ message_id: number;
+ thread_id: number;
+ parent_id: number | null;
+ author_id: string | null;
+ content: string;
+ created_at: string;
+ updated_at: string;
+}
+
+// Regex pattern to match @[uuid] mentions (matches backend MENTION_PATTERN)
+const MENTION_PATTERN = /@\[([0-9a-fA-F-]{36})\]/g;
+
+type MemberInfo = Pick;
+
+/**
+ * Render mentions in content by replacing @[uuid] with @{DisplayName}
+ */
+function renderMentions(content: string, memberMap: Map): string {
+ return content.replace(MENTION_PATTERN, (match, uuid) => {
+ const member = memberMap.get(uuid);
+ if (member?.user_display_name) {
+ return `@{${member.user_display_name}}`;
+ }
+ return match;
+ });
+}
+
+/**
+ * Build member lookup map from membersData
+ */
+function buildMemberMap(membersData: Membership[] | undefined): Map {
+ const map = new Map();
+ if (membersData) {
+ for (const m of membersData) {
+ map.set(m.user_id, {
+ user_display_name: m.user_display_name,
+ user_avatar_url: m.user_avatar_url,
+ user_email: m.user_email,
+ });
+ }
+ }
+ return map;
+}
+
+/**
+ * Build author object from member data
+ */
+function buildAuthor(authorId: string | null, memberMap: Map): Author | null {
+ if (!authorId) return null;
+ const m = memberMap.get(authorId);
+ if (!m) return null;
+ return {
+ id: authorId,
+ display_name: m.user_display_name ?? null,
+ avatar_url: m.user_avatar_url ?? null,
+ email: m.user_email ?? "",
+ };
+}
+
+/**
+ * Check if a comment has been edited by comparing timestamps.
+ * Uses a small threshold to handle precision differences.
+ */
+function isEdited(createdAt: string, updatedAt: string): boolean {
+ const created = new Date(createdAt).getTime();
+ const updated = new Date(updatedAt).getTime();
+ // Consider edited if updated_at is more than 1 second after created_at
+ return updated - created > 1000;
+}
+
+/**
+ * Transform raw comment to CommentReply
+ */
+function transformReply(
+ raw: RawCommentRow,
+ memberMap: Map,
+ currentUserId: string | undefined,
+ isOwner: boolean
+): CommentReply {
+ return {
+ id: raw.id,
+ content: raw.content,
+ content_rendered: renderMentions(raw.content, memberMap),
+ author: buildAuthor(raw.author_id, memberMap),
+ created_at: raw.created_at,
+ updated_at: raw.updated_at,
+ is_edited: isEdited(raw.created_at, raw.updated_at),
+ can_edit: currentUserId === raw.author_id,
+ can_delete: currentUserId === raw.author_id || isOwner,
+ };
+}
+
+/**
+ * Transform raw comments to Comment with replies
+ */
+function transformComments(
+ rawComments: RawCommentRow[],
+ memberMap: Map,
+ currentUserId: string | undefined,
+ isOwner: boolean
+): Map {
+ // Group comments by message_id
+ const byMessage = new Map<
+ number,
+ { topLevel: RawCommentRow[]; replies: Map }
+ >();
+
+ for (const raw of rawComments) {
+ if (!byMessage.has(raw.message_id)) {
+ byMessage.set(raw.message_id, { topLevel: [], replies: new Map() });
+ }
+ const group = byMessage.get(raw.message_id)!;
+
+ if (raw.parent_id === null) {
+ group.topLevel.push(raw);
+ } else {
+ if (!group.replies.has(raw.parent_id)) {
+ group.replies.set(raw.parent_id, []);
+ }
+ group.replies.get(raw.parent_id)!.push(raw);
+ }
+ }
+
+ // Transform to Comment objects grouped by message_id
+ const result = new Map();
+
+ for (const [messageId, group] of byMessage) {
+ const comments: Comment[] = group.topLevel.map((raw) => {
+ const replies = (group.replies.get(raw.id) || [])
+ .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
+ .map((r) => transformReply(r, memberMap, currentUserId, isOwner));
+
+ return {
+ id: raw.id,
+ message_id: raw.message_id,
+ content: raw.content,
+ content_rendered: renderMentions(raw.content, memberMap),
+ author: buildAuthor(raw.author_id, memberMap),
+ created_at: raw.created_at,
+ updated_at: raw.updated_at,
+ is_edited: isEdited(raw.created_at, raw.updated_at),
+ can_edit: currentUserId === raw.author_id,
+ can_delete: currentUserId === raw.author_id || isOwner,
+ reply_count: replies.length,
+ replies,
+ };
+ });
+
+ // Sort by created_at
+ comments.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
+ result.set(messageId, comments);
+ }
+
+ return result;
+}
+
+/**
+ * Hook for syncing comments with Electric SQL real-time sync.
+ *
+ * Syncs ALL comments for a thread in ONE subscription, then updates
+ * React Query cache for each message. This avoids N subscriptions for N messages.
+ *
+ * @param threadId - The thread ID to sync comments for
+ */
+export function useCommentsElectric(threadId: number | null) {
+ const electricClient = useElectricClient();
+ const queryClient = useQueryClient();
+
+ const { data: membersData } = useAtomValue(membersAtom);
+ const { data: currentUser } = useAtomValue(currentUserAtom);
+ const { data: myAccess } = useAtomValue(myAccessAtom);
+
+ const memberMap = useMemo(() => buildMemberMap(membersData), [membersData]);
+ const currentUserId = currentUser?.id;
+ const isOwner = myAccess?.is_owner ?? false;
+
+ // Use refs for values needed in live query callback to avoid stale closures
+ const memberMapRef = useRef(memberMap);
+ const currentUserIdRef = useRef(currentUserId);
+ const isOwnerRef = useRef(isOwner);
+ const queryClientRef = useRef(queryClient);
+
+ // Keep refs updated
+ useEffect(() => {
+ memberMapRef.current = memberMap;
+ currentUserIdRef.current = currentUserId;
+ isOwnerRef.current = isOwner;
+ queryClientRef.current = queryClient;
+ }, [memberMap, currentUserId, isOwner, queryClient]);
+
+ const syncHandleRef = useRef(null);
+ const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
+ const syncKeyRef = useRef(null);
+ const streamUpdateDebounceRef = useRef | null>(null);
+
+ // Stable callback that uses refs for fresh values
+ const updateReactQueryCache = useCallback((rows: RawCommentRow[]) => {
+ const commentsByMessage = transformComments(
+ rows,
+ memberMapRef.current,
+ currentUserIdRef.current,
+ isOwnerRef.current
+ );
+
+ for (const [messageId, comments] of commentsByMessage) {
+ const cacheKey = cacheKeys.comments.byMessage(messageId);
+ queryClientRef.current.setQueryData(cacheKey, {
+ comments,
+ total_count: comments.length,
+ });
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!threadId || !electricClient) {
+ return;
+ }
+
+ const syncKey = `comments_${threadId}`;
+ if (syncKeyRef.current === syncKey) {
+ return;
+ }
+
+ // Capture in local variable for use in async functions
+ const client = electricClient;
+
+ let mounted = true;
+ syncKeyRef.current = syncKey;
+
+ async function startSync() {
+ try {
+ const handle = await client.syncShape({
+ table: "chat_comments",
+ where: `thread_id = ${threadId}`,
+ columns: [
+ "id",
+ "message_id",
+ "thread_id",
+ "parent_id",
+ "author_id",
+ "content",
+ "created_at",
+ "updated_at",
+ ],
+ primaryKey: ["id"],
+ });
+
+ if (!handle.isUpToDate && handle.initialSyncPromise) {
+ try {
+ await Promise.race([
+ handle.initialSyncPromise,
+ new Promise((resolve) => setTimeout(resolve, 3000)),
+ ]);
+ } catch {
+ // Initial sync timeout - continue anyway
+ }
+ }
+
+ if (!mounted) {
+ handle.unsubscribe();
+ return;
+ }
+
+ syncHandleRef.current = handle;
+
+ // Fetch initial comments and update cache
+ await fetchAndUpdateCache();
+
+ // Set up live query for real-time updates
+ await setupLiveQuery();
+
+ // Subscribe to the sync stream for real-time updates from Electric SQL
+ // This ensures we catch updates even if PGlite live query misses them
+ if (handle.stream) {
+ const stream = handle.stream as {
+ subscribe?: (callback: (messages: unknown[]) => void) => void;
+ };
+ if (typeof stream.subscribe === "function") {
+ stream.subscribe((messages: unknown[]) => {
+ if (!mounted) return;
+ // When Electric sync receives new data, refresh from PGlite
+ // This handles cases where live query might miss the update
+ if (messages && messages.length > 0) {
+ // Debounce the refresh to avoid excessive queries
+ if (streamUpdateDebounceRef.current) {
+ clearTimeout(streamUpdateDebounceRef.current);
+ }
+ streamUpdateDebounceRef.current = setTimeout(() => {
+ if (mounted) {
+ fetchAndUpdateCache();
+ }
+ }, STREAM_UPDATE_DEBOUNCE_MS);
+ }
+ });
+ }
+ }
+ } catch {
+ // Sync failed - will retry on next mount
+ }
+ }
+
+ async function fetchAndUpdateCache() {
+ try {
+ const result = await client.db.query(
+ `SELECT id, message_id, thread_id, parent_id, author_id, content, created_at, updated_at
+ FROM chat_comments
+ WHERE thread_id = $1
+ ORDER BY created_at ASC`,
+ [threadId]
+ );
+
+ if (mounted && result.rows) {
+ updateReactQueryCache(result.rows);
+ }
+ } catch {
+ // Query failed - data will be fetched from API
+ }
+ }
+
+ async function setupLiveQuery() {
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const db = client.db as any;
+
+ if (db.live?.query && typeof db.live.query === "function") {
+ const liveQuery = await db.live.query(
+ `SELECT id, message_id, thread_id, parent_id, author_id, content, created_at, updated_at
+ FROM chat_comments
+ WHERE thread_id = $1
+ ORDER BY created_at ASC`,
+ [threadId]
+ );
+
+ if (!mounted) {
+ liveQuery.unsubscribe?.();
+ return;
+ }
+
+ // Set initial results
+ if (liveQuery.initialResults?.rows) {
+ updateReactQueryCache(liveQuery.initialResults.rows);
+ } else if (liveQuery.rows) {
+ updateReactQueryCache(liveQuery.rows);
+ }
+
+ // Subscribe to changes
+ if (typeof liveQuery.subscribe === "function") {
+ liveQuery.subscribe((result: { rows: RawCommentRow[] }) => {
+ if (mounted && result.rows) {
+ updateReactQueryCache(result.rows);
+ }
+ });
+ }
+
+ if (typeof liveQuery.unsubscribe === "function") {
+ liveQueryRef.current = liveQuery;
+ }
+ }
+ } catch {
+ // Live query setup failed - will use initial fetch only
+ }
+ }
+
+ startSync();
+
+ return () => {
+ mounted = false;
+ syncKeyRef.current = null;
+
+ // Clear debounce timeout
+ if (streamUpdateDebounceRef.current) {
+ clearTimeout(streamUpdateDebounceRef.current);
+ streamUpdateDebounceRef.current = null;
+ }
+
+ if (syncHandleRef.current) {
+ syncHandleRef.current.unsubscribe();
+ syncHandleRef.current = null;
+ }
+ if (liveQueryRef.current) {
+ liveQueryRef.current.unsubscribe();
+ liveQueryRef.current = null;
+ }
+ };
+ }, [threadId, electricClient, updateReactQueryCache]);
+}
diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts
new file mode 100644
index 000000000..4c26ddcb9
--- /dev/null
+++ b/surfsense_web/hooks/use-inbox.ts
@@ -0,0 +1,523 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
+import { notificationsApiService } from "@/lib/apis/notifications-api.service";
+import type { SyncHandle } from "@/lib/electric/client";
+import { useElectricClient } from "@/lib/electric/context";
+
+export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
+
+const PAGE_SIZE = 50;
+const SYNC_WINDOW_DAYS = 14;
+
+/**
+ * Check if an item is older than the sync window
+ */
+function isOlderThanSyncWindow(createdAt: string): boolean {
+ const cutoffDate = new Date();
+ cutoffDate.setDate(cutoffDate.getDate() - SYNC_WINDOW_DAYS);
+ return new Date(createdAt) < cutoffDate;
+}
+
+/**
+ * Deduplicate by ID and sort by created_at descending.
+ * This is the SINGLE source of truth for deduplication - prevents race conditions.
+ */
+function deduplicateAndSort(items: InboxItem[]): InboxItem[] {
+ const seen = new Map();
+ for (const item of items) {
+ if (!seen.has(item.id)) {
+ seen.set(item.id, item);
+ }
+ }
+ return Array.from(seen.values()).sort(
+ (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
+ );
+}
+
+/**
+ * Calculate the cutoff date for sync window
+ */
+function getSyncCutoffDate(): string {
+ const cutoff = new Date();
+ cutoff.setDate(cutoff.getDate() - SYNC_WINDOW_DAYS);
+ return cutoff.toISOString();
+}
+
+/**
+ * Convert a date value to ISO string format
+ */
+function toISOString(date: string | Date | null | undefined): string | null {
+ if (!date) return null;
+ if (date instanceof Date) return date.toISOString();
+ if (typeof date === "string") {
+ if (date.includes("T")) return date;
+ try {
+ return new Date(date).toISOString();
+ } catch {
+ return date;
+ }
+ }
+ return null;
+}
+
+/**
+ * Hook for managing inbox items with Electric SQL real-time sync + API fallback
+ *
+ * Architecture (Simplified & Race-Condition Free):
+ * - Electric SQL: Syncs recent items (within SYNC_WINDOW_DAYS) for real-time updates
+ * - Live Query: Provides reactive first page from PGLite
+ * - API: Handles all pagination (more reliable than mixing with Electric)
+ *
+ * Key Design Decisions:
+ * 1. No mutable refs for cursor - cursor computed from current state
+ * 2. Single deduplicateAndSort function - prevents inconsistencies
+ * 3. Filter-based preservation in live query - prevents data loss
+ * 4. Auto-fetch from API when Electric returns 0 items
+ *
+ * @param userId - The user ID to fetch inbox items for
+ * @param searchSpaceId - The search space ID to filter inbox items
+ * @param typeFilter - Optional inbox item type to filter by
+ */
+export function useInbox(
+ userId: string | null,
+ searchSpaceId: number | null,
+ typeFilter: InboxItemTypeEnum | null = null
+) {
+ const electricClient = useElectricClient();
+
+ const [inboxItems, setInboxItems] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [hasMore, setHasMore] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Split unread count tracking for accurate counts with 14-day sync window
+ // olderUnreadCount = unread items OLDER than sync window (from server, static until reconciliation)
+ // recentUnreadCount = unread items within sync window (from live query, real-time)
+ const [olderUnreadCount, setOlderUnreadCount] = useState(0);
+ const [recentUnreadCount, setRecentUnreadCount] = useState(0);
+
+ const syncHandleRef = useRef(null);
+ const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
+ const userSyncKeyRef = useRef(null);
+ const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
+
+ // Total unread = older (static from server) + recent (live from Electric)
+ const totalUnreadCount = olderUnreadCount + recentUnreadCount;
+
+ // EFFECT 1: Electric SQL sync for real-time updates
+ useEffect(() => {
+ if (!userId || !electricClient) {
+ setLoading(!electricClient);
+ return;
+ }
+
+ const client = electricClient;
+ let mounted = true;
+
+ async function startSync() {
+ try {
+ const cutoffDate = getSyncCutoffDate();
+ const userSyncKey = `inbox_${userId}_${cutoffDate}`;
+
+ // Skip if already syncing with this key
+ if (userSyncKeyRef.current === userSyncKey) return;
+
+ // Clean up previous sync
+ if (syncHandleRef.current) {
+ syncHandleRef.current.unsubscribe();
+ syncHandleRef.current = null;
+ }
+
+ console.log("[useInbox] Starting sync for:", userId);
+ userSyncKeyRef.current = userSyncKey;
+
+ const handle = await client.syncShape({
+ table: "notifications",
+ where: `user_id = '${userId}' AND created_at > '${cutoffDate}'`,
+ primaryKey: ["id"],
+ });
+
+ // Wait for initial sync with timeout
+ if (!handle.isUpToDate && handle.initialSyncPromise) {
+ await Promise.race([
+ handle.initialSyncPromise,
+ new Promise((resolve) => setTimeout(resolve, 3000)),
+ ]);
+ }
+
+ if (!mounted) {
+ handle.unsubscribe();
+ return;
+ }
+
+ syncHandleRef.current = handle;
+ setLoading(false);
+ setError(null);
+ } catch (err) {
+ if (!mounted) return;
+ console.error("[useInbox] Sync failed:", err);
+ setError(err instanceof Error ? err : new Error("Sync failed"));
+ setLoading(false);
+ }
+ }
+
+ startSync();
+
+ return () => {
+ mounted = false;
+ userSyncKeyRef.current = null;
+ if (syncHandleRef.current) {
+ syncHandleRef.current.unsubscribe();
+ syncHandleRef.current = null;
+ }
+ };
+ }, [userId, electricClient]);
+
+ // Reset when filters change
+ useEffect(() => {
+ setHasMore(true);
+ setInboxItems([]);
+ // Reset count states - will be refetched by the unread count effect
+ setOlderUnreadCount(0);
+ setRecentUnreadCount(0);
+ }, [userId, searchSpaceId, typeFilter]);
+
+ // EFFECT 2: Live query for real-time updates + auto-fetch from API if empty
+ useEffect(() => {
+ if (!userId || !electricClient) return;
+
+ const client = electricClient;
+ let mounted = true;
+
+ async function setupLiveQuery() {
+ // Clean up previous live query
+ if (liveQueryRef.current) {
+ liveQueryRef.current.unsubscribe();
+ liveQueryRef.current = null;
+ }
+
+ try {
+ const cutoff = getSyncCutoffDate();
+
+ const query = `SELECT * FROM notifications
+ WHERE user_id = $1
+ AND (search_space_id = $2 OR search_space_id IS NULL)
+ AND created_at > '${cutoff}'
+ ${typeFilter ? "AND type = $3" : ""}
+ ORDER BY created_at DESC
+ LIMIT ${PAGE_SIZE}`;
+
+ const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
+
+ const db = client.db as any;
+
+ // Initial fetch from PGLite - no validation needed, schema is enforced by Electric SQL sync
+ const result = await client.db.query(query, params);
+
+ if (mounted && result.rows) {
+ const items = deduplicateAndSort(result.rows);
+ setInboxItems(items);
+
+ // AUTO-FETCH: If Electric returned 0 items, check API for older items
+ // This handles the edge case where user has no recent notifications
+ // but has older ones outside the sync window
+ if (items.length === 0) {
+ console.log(
+ "[useInbox] Electric returned 0 items, checking API for older notifications"
+ );
+ try {
+ // Use the API service with proper Zod validation for API responses
+ const data = await notificationsApiService.getNotifications({
+ queryParams: {
+ search_space_id: searchSpaceId ?? undefined,
+ type: typeFilter ?? undefined,
+ limit: PAGE_SIZE,
+ },
+ });
+
+ if (mounted) {
+ if (data.items.length > 0) {
+ setInboxItems(data.items);
+ }
+ setHasMore(data.has_more);
+ }
+ } catch (err) {
+ console.error("[useInbox] API fallback failed:", err);
+ }
+ }
+ }
+
+ // Set up live query for real-time updates
+ if (db.live?.query) {
+ const liveQuery = await db.live.query(query, params);
+
+ if (!mounted) {
+ liveQuery.unsubscribe?.();
+ return;
+ }
+
+ if (liveQuery.subscribe) {
+ // Live query data comes from PGlite - no validation needed
+ liveQuery.subscribe((result: { rows: InboxItem[] }) => {
+ if (mounted && result.rows) {
+ const liveItems = result.rows;
+
+ setInboxItems((prev) => {
+ const liveItemIds = new Set(liveItems.map((item) => item.id));
+
+ // FIXED: Keep ALL items not in live result (not just slice)
+ // This prevents data loss when new notifications push items
+ // out of the LIMIT window
+ const itemsToKeep = prev.filter((item) => !liveItemIds.has(item.id));
+
+ return deduplicateAndSort([...liveItems, ...itemsToKeep]);
+ });
+ }
+ });
+ }
+
+ if (liveQuery.unsubscribe) {
+ liveQueryRef.current = liveQuery;
+ }
+ }
+ } catch (err) {
+ console.error("[useInbox] Live query error:", err);
+ }
+ }
+
+ setupLiveQuery();
+
+ return () => {
+ mounted = false;
+ if (liveQueryRef.current) {
+ liveQueryRef.current.unsubscribe();
+ liveQueryRef.current = null;
+ }
+ };
+ }, [userId, searchSpaceId, typeFilter, electricClient]);
+
+ // EFFECT 3: Dedicated unread count sync with split tracking
+ // - Fetches server count on mount (accurate total)
+ // - Sets up live query for recent count (real-time updates)
+ // - Handles items older than sync window separately
+ useEffect(() => {
+ if (!userId || !electricClient) return;
+
+ const client = electricClient;
+ let mounted = true;
+
+ async function setupUnreadCountSync() {
+ // Cleanup previous live query
+ if (unreadCountLiveQueryRef.current) {
+ unreadCountLiveQueryRef.current.unsubscribe();
+ unreadCountLiveQueryRef.current = null;
+ }
+
+ try {
+ // STEP 1: Fetch server counts (total and recent) - guaranteed accurate
+ console.log("[useInbox] Fetching unread count from server");
+ const serverCounts = await notificationsApiService.getUnreadCount(
+ searchSpaceId ?? undefined
+ );
+
+ if (mounted) {
+ // Calculate older count = total - recent
+ const olderCount = serverCounts.total_unread - serverCounts.recent_unread;
+ setOlderUnreadCount(olderCount);
+ setRecentUnreadCount(serverCounts.recent_unread);
+ console.log(
+ `[useInbox] Server counts: total=${serverCounts.total_unread}, recent=${serverCounts.recent_unread}, older=${olderCount}`
+ );
+ }
+
+ // STEP 2: Set up PGLite live query for RECENT unread count only
+ // This provides real-time updates for notifications within sync window
+ const db = client.db as any;
+ const cutoff = getSyncCutoffDate();
+
+ // Count query - NO LIMIT, counts all unread in synced window
+ const countQuery = `
+ SELECT COUNT(*) as count FROM notifications
+ WHERE user_id = $1
+ AND (search_space_id = $2 OR search_space_id IS NULL)
+ AND created_at > '${cutoff}'
+ AND read = false
+ ${typeFilter ? "AND type = $3" : ""}
+ `;
+ const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
+
+ if (db.live?.query) {
+ const liveQuery = await db.live.query(countQuery, params);
+
+ if (!mounted) {
+ liveQuery.unsubscribe?.();
+ return;
+ }
+
+ if (liveQuery.subscribe) {
+ liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => {
+ if (mounted && result.rows?.[0]) {
+ const liveCount = Number(result.rows[0].count) || 0;
+ // Update recent count from live query
+ // This fires in real-time when Electric syncs new/updated notifications
+ setRecentUnreadCount(liveCount);
+ }
+ });
+ }
+
+ if (liveQuery.unsubscribe) {
+ unreadCountLiveQueryRef.current = liveQuery;
+ }
+ }
+ } catch (err) {
+ console.error("[useInbox] Unread count sync error:", err);
+ // On error, counts will remain at 0 or previous values
+ // The items-based count will be the fallback
+ }
+ }
+
+ setupUnreadCountSync();
+
+ return () => {
+ mounted = false;
+ if (unreadCountLiveQueryRef.current) {
+ unreadCountLiveQueryRef.current.unsubscribe();
+ unreadCountLiveQueryRef.current = null;
+ }
+ };
+ }, [userId, searchSpaceId, typeFilter, electricClient]);
+
+ // loadMore - Pure cursor-based pagination, no race conditions
+ // Cursor is computed from current state, not stored in refs
+ const loadMore = useCallback(async () => {
+ // Removed inboxItems.length === 0 check to allow loading older items
+ // when Electric returns 0 items
+ if (!userId || loadingMore || !hasMore) return;
+
+ setLoadingMore(true);
+
+ try {
+ // Cursor is computed from current state - no stale refs possible
+ const oldestItem = inboxItems.length > 0 ? inboxItems[inboxItems.length - 1] : null;
+ const beforeDate = oldestItem ? toISOString(oldestItem.created_at) : null;
+
+ console.log("[useInbox] Loading more, before:", beforeDate ?? "none (initial)");
+
+ // Use the API service with proper Zod validation
+ const data = await notificationsApiService.getNotifications({
+ queryParams: {
+ search_space_id: searchSpaceId ?? undefined,
+ type: typeFilter ?? undefined,
+ before_date: beforeDate ?? undefined,
+ limit: PAGE_SIZE,
+ },
+ });
+
+ if (data.items.length > 0) {
+ // Functional update ensures we always merge with latest state
+ // Items are already validated by the API service
+ setInboxItems((prev) => deduplicateAndSort([...prev, ...data.items]));
+ }
+
+ // Use API's has_more flag
+ setHasMore(data.has_more);
+ } catch (err) {
+ console.error("[useInbox] Load more failed:", err);
+ } finally {
+ setLoadingMore(false);
+ }
+ }, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]);
+
+ // Mark inbox item as read with optimistic update
+ // Handles both recent items (live query updates count) and older items (manual count decrement)
+ const markAsRead = useCallback(
+ async (itemId: number) => {
+ // Find the item to check if it's older than sync window
+ const item = inboxItems.find((i) => i.id === itemId);
+ const isOlderItem = item && !item.read && isOlderThanSyncWindow(item.created_at);
+
+ // Optimistic update: mark as read immediately for instant UI feedback
+ setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: true } : i)));
+
+ // If older item, manually decrement older count
+ // (live query won't see items outside sync window)
+ if (isOlderItem) {
+ setOlderUnreadCount((prev) => Math.max(0, prev - 1));
+ }
+
+ try {
+ // Use the API service with proper Zod validation
+ const result = await notificationsApiService.markAsRead({ notificationId: itemId });
+
+ if (!result.success) {
+ // Rollback on error
+ setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
+ if (isOlderItem) {
+ setOlderUnreadCount((prev) => prev + 1);
+ }
+ }
+ // If successful, Electric SQL will sync the change and live query will update
+ // This ensures eventual consistency even if optimistic update was wrong
+ return result.success;
+ } catch (err) {
+ console.error("Failed to mark as read:", err);
+ // Rollback on error
+ setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
+ if (isOlderItem) {
+ setOlderUnreadCount((prev) => prev + 1);
+ }
+ return false;
+ }
+ },
+ [inboxItems]
+ );
+
+ // Mark all inbox items as read with optimistic update
+ // Resets both older and recent counts to 0
+ const markAllAsRead = useCallback(async () => {
+ // Store previous counts for potential rollback
+ const prevOlderCount = olderUnreadCount;
+ const prevRecentCount = recentUnreadCount;
+
+ // Optimistic update: mark all as read immediately for instant UI feedback
+ setInboxItems((prev) => prev.map((item) => ({ ...item, read: true })));
+ setOlderUnreadCount(0);
+ setRecentUnreadCount(0);
+
+ try {
+ // Use the API service with proper Zod validation
+ const result = await notificationsApiService.markAllAsRead();
+
+ if (!result.success) {
+ console.error("Failed to mark all as read");
+ // Rollback counts on error
+ setOlderUnreadCount(prevOlderCount);
+ setRecentUnreadCount(prevRecentCount);
+ }
+ // Electric SQL will sync and live query will ensure consistency
+ return result.success;
+ } catch (err) {
+ console.error("Failed to mark all as read:", err);
+ // Rollback counts on error
+ setOlderUnreadCount(prevOlderCount);
+ setRecentUnreadCount(prevRecentCount);
+ return false;
+ }
+ }, [olderUnreadCount, recentUnreadCount]);
+
+ return {
+ inboxItems,
+ unreadCount: totalUnreadCount,
+ markAsRead,
+ markAllAsRead,
+ loading,
+ loadingMore,
+ hasMore,
+ loadMore,
+ isUsingApiFallback: true, // Always use API for pagination
+ error,
+ };
+}
diff --git a/surfsense_web/hooks/use-messages-electric.ts b/surfsense_web/hooks/use-messages-electric.ts
new file mode 100644
index 000000000..e8c82e92b
--- /dev/null
+++ b/surfsense_web/hooks/use-messages-electric.ts
@@ -0,0 +1,154 @@
+"use client";
+
+import { useCallback, useEffect, useRef } from "react";
+import type { RawMessage } from "@/contracts/types/chat-messages.types";
+import type { SyncHandle } from "@/lib/electric/client";
+import { useElectricClient } from "@/lib/electric/context";
+
+/**
+ * Syncs chat messages for a thread via Electric SQL.
+ * Calls onMessagesUpdate when messages change.
+ */
+export function useMessagesElectric(
+ threadId: number | null,
+ onMessagesUpdate: (messages: RawMessage[]) => void
+) {
+ const electricClient = useElectricClient();
+
+ const syncHandleRef = useRef(null);
+ const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
+ const syncKeyRef = useRef(null);
+ const onMessagesUpdateRef = useRef(onMessagesUpdate);
+
+ useEffect(() => {
+ onMessagesUpdateRef.current = onMessagesUpdate;
+ }, [onMessagesUpdate]);
+
+ const handleMessagesUpdate = useCallback((rows: RawMessage[]) => {
+ onMessagesUpdateRef.current(rows);
+ }, []);
+
+ useEffect(() => {
+ if (!threadId || !electricClient) {
+ return;
+ }
+
+ const syncKey = `messages_${threadId}`;
+ if (syncKeyRef.current === syncKey) {
+ return;
+ }
+
+ const client = electricClient;
+ let mounted = true;
+ syncKeyRef.current = syncKey;
+
+ async function startSync() {
+ try {
+ const handle = await client.syncShape({
+ table: "new_chat_messages",
+ where: `thread_id = ${threadId}`,
+ columns: ["id", "thread_id", "role", "content", "author_id", "created_at"],
+ primaryKey: ["id"],
+ });
+
+ if (!handle.isUpToDate && handle.initialSyncPromise) {
+ try {
+ await Promise.race([
+ handle.initialSyncPromise,
+ new Promise((resolve) => setTimeout(resolve, 3000)),
+ ]);
+ } catch {
+ // Timeout
+ }
+ }
+
+ if (!mounted) {
+ handle.unsubscribe();
+ return;
+ }
+
+ syncHandleRef.current = handle;
+ await fetchMessages();
+ await setupLiveQuery();
+ } catch {
+ // Sync failed
+ }
+ }
+
+ async function fetchMessages() {
+ try {
+ const result = await client.db.query(
+ `SELECT id, thread_id, role, content, author_id, created_at
+ FROM new_chat_messages
+ WHERE thread_id = $1
+ ORDER BY created_at ASC`,
+ [threadId]
+ );
+
+ if (mounted && result.rows) {
+ handleMessagesUpdate(result.rows);
+ }
+ } catch {
+ // Query failed
+ }
+ }
+
+ async function setupLiveQuery() {
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const db = client.db as any;
+
+ if (db.live?.query && typeof db.live.query === "function") {
+ const liveQuery = await db.live.query(
+ `SELECT id, thread_id, role, content, author_id, created_at
+ FROM new_chat_messages
+ WHERE thread_id = $1
+ ORDER BY created_at ASC`,
+ [threadId]
+ );
+
+ if (!mounted) {
+ liveQuery.unsubscribe?.();
+ return;
+ }
+
+ if (liveQuery.initialResults?.rows) {
+ handleMessagesUpdate(liveQuery.initialResults.rows);
+ } else if (liveQuery.rows) {
+ handleMessagesUpdate(liveQuery.rows);
+ }
+
+ if (typeof liveQuery.subscribe === "function") {
+ liveQuery.subscribe((result: { rows: RawMessage[] }) => {
+ if (mounted && result.rows) {
+ handleMessagesUpdate(result.rows);
+ }
+ });
+ }
+
+ if (typeof liveQuery.unsubscribe === "function") {
+ liveQueryRef.current = liveQuery;
+ }
+ }
+ } catch {
+ // Live query failed
+ }
+ }
+
+ startSync();
+
+ return () => {
+ mounted = false;
+ syncKeyRef.current = null;
+
+ if (syncHandleRef.current) {
+ syncHandleRef.current.unsubscribe();
+ syncHandleRef.current = null;
+ }
+ if (liveQueryRef.current) {
+ liveQueryRef.current.unsubscribe();
+ liveQueryRef.current = null;
+ }
+ };
+ }, [threadId, electricClient, handleMessagesUpdate]);
+}
diff --git a/surfsense_web/hooks/use-notifications.ts b/surfsense_web/hooks/use-notifications.ts
deleted file mode 100644
index eca00a935..000000000
--- a/surfsense_web/hooks/use-notifications.ts
+++ /dev/null
@@ -1,334 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useRef, useState } from "react";
-import type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
-import { authenticatedFetch } from "@/lib/auth-utils";
-import type { SyncHandle } from "@/lib/electric/client";
-import { useElectricClient } from "@/lib/electric/context";
-
-export type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
-
-/**
- * Hook for managing notifications with Electric SQL real-time sync
- *
- * Uses the Electric client from context (provided by ElectricProvider)
- * instead of initializing its own - prevents race conditions and memory leaks
- *
- * Architecture:
- * - User-level sync: Syncs ALL notifications for a user (runs once per user)
- * - Search-space-level query: Filters notifications by searchSpaceId (updates on search space change)
- *
- * This separation ensures smooth transitions when switching search spaces (no flash).
- *
- * @param userId - The user ID to fetch notifications for
- * @param searchSpaceId - The search space ID to filter notifications (null shows global notifications only)
- * @param typeFilter - Optional notification type to filter by (null shows all types)
- */
-export function useNotifications(
- userId: string | null,
- searchSpaceId: number | null,
- typeFilter: NotificationTypeEnum | null = null
-) {
- // Get Electric client from context - ElectricProvider handles initialization
- const electricClient = useElectricClient();
-
- const [notifications, setNotifications] = useState([]);
- const [totalUnreadCount, setTotalUnreadCount] = useState(0);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const syncHandleRef = useRef(null);
- const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
- const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
-
- // Track user-level sync key to prevent duplicate sync subscriptions
- const userSyncKeyRef = useRef(null);
-
- // EFFECT 1: User-level sync - runs once per user, syncs ALL notifications
- useEffect(() => {
- if (!userId || !electricClient) {
- setLoading(!electricClient);
- return;
- }
-
- const userSyncKey = `notifications_${userId}`;
- if (userSyncKeyRef.current === userSyncKey) {
- // Already syncing for this user
- return;
- }
-
- let mounted = true;
- userSyncKeyRef.current = userSyncKey;
-
- async function startUserSync() {
- try {
- console.log("[useNotifications] Starting user-level sync for:", userId);
-
- // Sync ALL notifications for this user (cached via syncShape caching)
- const handle = await electricClient.syncShape({
- table: "notifications",
- where: `user_id = '${userId}'`,
- primaryKey: ["id"],
- });
-
- console.log("[useNotifications] User sync started:", {
- isUpToDate: handle.isUpToDate,
- });
-
- // Wait for initial sync with timeout
- if (!handle.isUpToDate && handle.initialSyncPromise) {
- try {
- await Promise.race([
- handle.initialSyncPromise,
- new Promise((resolve) => setTimeout(resolve, 2000)),
- ]);
- } catch (syncErr) {
- console.error("[useNotifications] Initial sync failed:", syncErr);
- }
- }
-
- if (!mounted) {
- handle.unsubscribe();
- return;
- }
-
- syncHandleRef.current = handle;
- setLoading(false);
- setError(null);
- } catch (err) {
- if (!mounted) return;
- console.error("[useNotifications] Failed to start user sync:", err);
- setError(err instanceof Error ? err : new Error("Failed to sync notifications"));
- setLoading(false);
- }
- }
-
- startUserSync();
-
- return () => {
- mounted = false;
- userSyncKeyRef.current = null;
-
- if (syncHandleRef.current) {
- syncHandleRef.current.unsubscribe();
- syncHandleRef.current = null;
- }
- };
- }, [userId, electricClient]);
-
- // EFFECT 2: Search-space-level query - updates when searchSpaceId or typeFilter changes
- // This runs independently of sync, allowing smooth transitions between search spaces
- useEffect(() => {
- if (!userId || !electricClient) {
- return;
- }
-
- let mounted = true;
-
- async function updateQuery() {
- // Clean up previous live query (but DON'T clear notifications - keep showing old until new arrive)
- if (liveQueryRef.current) {
- liveQueryRef.current.unsubscribe();
- liveQueryRef.current = null;
- }
-
- try {
- console.log(
- "[useNotifications] Updating query for searchSpace:",
- searchSpaceId,
- "typeFilter:",
- typeFilter
- );
-
- // Build query with optional type filter
- const baseQuery = `SELECT * FROM notifications
- WHERE user_id = $1
- AND (search_space_id = $2 OR search_space_id IS NULL)`;
- const typeClause = typeFilter ? ` AND type = $3` : "";
- const orderClause = ` ORDER BY created_at DESC`;
- const fullQuery = baseQuery + typeClause + orderClause;
- const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
-
- // Fetch notifications for current search space immediately
- const result = await electricClient.db.query(fullQuery, params);
-
- if (mounted) {
- setNotifications(result.rows || []);
- }
-
- // Set up live query for real-time updates
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const db = electricClient.db as any;
-
- if (db.live?.query && typeof db.live.query === "function") {
- const liveQuery = await db.live.query(fullQuery, params);
-
- if (!mounted) {
- liveQuery.unsubscribe?.();
- return;
- }
-
- // Set initial results from live query
- if (liveQuery.initialResults?.rows) {
- setNotifications(liveQuery.initialResults.rows);
- } else if (liveQuery.rows) {
- setNotifications(liveQuery.rows);
- }
-
- // Subscribe to changes
- if (typeof liveQuery.subscribe === "function") {
- liveQuery.subscribe((result: { rows: Notification[] }) => {
- if (mounted && result.rows) {
- setNotifications(result.rows);
- }
- });
- }
-
- if (typeof liveQuery.unsubscribe === "function") {
- liveQueryRef.current = liveQuery;
- }
- }
- } catch (err) {
- console.error("[useNotifications] Failed to update query:", err);
- }
- }
-
- updateQuery();
-
- return () => {
- mounted = false;
- if (liveQueryRef.current) {
- liveQueryRef.current.unsubscribe();
- liveQueryRef.current = null;
- }
- };
- }, [userId, searchSpaceId, typeFilter, electricClient]);
-
- // EFFECT 3: Total unread count - independent of type filter
- // This ensures the badge count stays consistent regardless of active filter
- useEffect(() => {
- if (!userId || !electricClient) {
- return;
- }
-
- let mounted = true;
-
- async function updateUnreadCount() {
- // Clean up previous live query
- if (unreadCountLiveQueryRef.current) {
- unreadCountLiveQueryRef.current.unsubscribe();
- unreadCountLiveQueryRef.current = null;
- }
-
- try {
- const countQuery = `SELECT COUNT(*) as count FROM notifications
- WHERE user_id = $1
- AND (search_space_id = $2 OR search_space_id IS NULL)
- AND read = false`;
-
- // Fetch initial count
- const result = await electricClient.db.query<{ count: number }>(countQuery, [
- userId,
- searchSpaceId,
- ]);
-
- if (mounted && result.rows?.[0]) {
- setTotalUnreadCount(Number(result.rows[0].count) || 0);
- }
-
- // Set up live query for real-time updates
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const db = electricClient.db as any;
-
- if (db.live?.query && typeof db.live.query === "function") {
- const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]);
-
- if (!mounted) {
- liveQuery.unsubscribe?.();
- return;
- }
-
- // Set initial results from live query
- if (liveQuery.initialResults?.rows?.[0]) {
- setTotalUnreadCount(Number(liveQuery.initialResults.rows[0].count) || 0);
- } else if (liveQuery.rows?.[0]) {
- setTotalUnreadCount(Number(liveQuery.rows[0].count) || 0);
- }
-
- // Subscribe to changes
- if (typeof liveQuery.subscribe === "function") {
- liveQuery.subscribe((result: { rows: { count: number }[] }) => {
- if (mounted && result.rows?.[0]) {
- setTotalUnreadCount(Number(result.rows[0].count) || 0);
- }
- });
- }
-
- if (typeof liveQuery.unsubscribe === "function") {
- unreadCountLiveQueryRef.current = liveQuery;
- }
- }
- } catch (err) {
- console.error("[useNotifications] Failed to update unread count:", err);
- }
- }
-
- updateUnreadCount();
-
- return () => {
- mounted = false;
- if (unreadCountLiveQueryRef.current) {
- unreadCountLiveQueryRef.current.unsubscribe();
- unreadCountLiveQueryRef.current = null;
- }
- };
- }, [userId, searchSpaceId, electricClient]);
-
- // Mark notification as read via backend API
- const markAsRead = useCallback(async (notificationId: number) => {
- try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${notificationId}/read`,
- { method: "PATCH" }
- );
-
- if (!response.ok) {
- const error = await response.json().catch(() => ({ detail: "Failed to mark as read" }));
- throw new Error(error.detail || "Failed to mark notification as read");
- }
-
- return true;
- } catch (err) {
- console.error("Failed to mark notification as read:", err);
- return false;
- }
- }, []);
-
- // Mark all notifications as read via backend API
- const markAllAsRead = useCallback(async () => {
- try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`,
- { method: "PATCH" }
- );
-
- if (!response.ok) {
- const error = await response.json().catch(() => ({ detail: "Failed to mark all as read" }));
- throw new Error(error.detail || "Failed to mark all notifications as read");
- }
-
- return true;
- } catch (err) {
- console.error("Failed to mark all notifications as read:", err);
- return false;
- }
- }, []);
-
- return {
- notifications,
- unreadCount: totalUnreadCount,
- markAsRead,
- markAllAsRead,
- loading,
- error,
- };
-}
diff --git a/surfsense_web/lib/apis/notifications-api.service.ts b/surfsense_web/lib/apis/notifications-api.service.ts
new file mode 100644
index 000000000..a9e81a81f
--- /dev/null
+++ b/surfsense_web/lib/apis/notifications-api.service.ts
@@ -0,0 +1,110 @@
+import {
+ type GetNotificationsRequest,
+ type GetNotificationsResponse,
+ type GetUnreadCountResponse,
+ getNotificationsRequest,
+ getNotificationsResponse,
+ getUnreadCountResponse,
+ type MarkAllNotificationsReadResponse,
+ type MarkNotificationReadRequest,
+ type MarkNotificationReadResponse,
+ markAllNotificationsReadResponse,
+ markNotificationReadRequest,
+ markNotificationReadResponse,
+} from "@/contracts/types/inbox.types";
+import { ValidationError } from "../error";
+import { baseApiService } from "./base-api.service";
+
+class NotificationsApiService {
+ /**
+ * Get notifications with pagination
+ */
+ getNotifications = async (
+ request: GetNotificationsRequest
+ ): Promise => {
+ const parsedRequest = getNotificationsRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+ const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ const { queryParams } = parsedRequest.data;
+
+ // Build query string from params
+ const params = new URLSearchParams();
+
+ if (queryParams.search_space_id !== undefined) {
+ params.append("search_space_id", String(queryParams.search_space_id));
+ }
+ if (queryParams.type) {
+ params.append("type", queryParams.type);
+ }
+ if (queryParams.before_date) {
+ params.append("before_date", queryParams.before_date);
+ }
+ if (queryParams.limit !== undefined) {
+ params.append("limit", String(queryParams.limit));
+ }
+ if (queryParams.offset !== undefined) {
+ params.append("offset", String(queryParams.offset));
+ }
+
+ const queryString = params.toString();
+
+ return baseApiService.get(
+ `/api/v1/notifications${queryString ? `?${queryString}` : ""}`,
+ getNotificationsResponse
+ );
+ };
+
+ /**
+ * Mark a single notification as read
+ */
+ markAsRead = async (
+ request: MarkNotificationReadRequest
+ ): Promise => {
+ const parsedRequest = markNotificationReadRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+ const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ const { notificationId } = parsedRequest.data;
+
+ return baseApiService.patch(
+ `/api/v1/notifications/${notificationId}/read`,
+ markNotificationReadResponse
+ );
+ };
+
+ /**
+ * Mark all notifications as read
+ */
+ markAllAsRead = async (): Promise => {
+ return baseApiService.patch("/api/v1/notifications/read-all", markAllNotificationsReadResponse);
+ };
+
+ /**
+ * Get unread notification count with split between total and recent
+ * - total_unread: All unread notifications
+ * - recent_unread: Unread within sync window (last 14 days)
+ */
+ getUnreadCount = async (searchSpaceId?: number): Promise => {
+ const params = new URLSearchParams();
+ if (searchSpaceId !== undefined) {
+ params.append("search_space_id", String(searchSpaceId));
+ }
+ const queryString = params.toString();
+
+ return baseApiService.get(
+ `/api/v1/notifications/unread-count${queryString ? `?${queryString}` : ""}`,
+ getUnreadCountResponse
+ );
+ };
+}
+
+export const notificationsApiService = new NotificationsApiService();
diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts
index 514185d23..148da58ec 100644
--- a/surfsense_web/lib/electric/client.ts
+++ b/surfsense_web/lib/electric/client.ts
@@ -53,8 +53,9 @@ const activeSyncHandles = new Map();
const pendingSyncs = new Map>();
// Version for sync state - increment this to force fresh sync when Electric config changes
-// Set to v2 for user-specific database architecture
-const SYNC_VERSION = 2;
+// v2: user-specific database architecture
+// v3: consistent cutoff date for sync+queries, visibility refresh support
+const SYNC_VERSION = 3;
// Database name prefix for identifying SurfSense databases
const DB_PREFIX = "surfsense-";
@@ -228,7 +229,6 @@ export async function initElectric(userId: string): Promise {
CREATE INDEX IF NOT EXISTS idx_documents_search_space_type ON documents(search_space_id, document_type);
`);
- // Create the chat_comment_mentions table schema in PGlite
await db.exec(`
CREATE TABLE IF NOT EXISTS chat_comment_mentions (
id INTEGER PRIMARY KEY,
@@ -241,6 +241,39 @@ export async function initElectric(userId: string): Promise {
CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_comment_id ON chat_comment_mentions(comment_id);
`);
+ // Create chat_comments table for live comment sync
+ await db.exec(`
+ CREATE TABLE IF NOT EXISTS chat_comments (
+ id INTEGER PRIMARY KEY,
+ message_id INTEGER NOT NULL,
+ thread_id INTEGER NOT NULL,
+ parent_id INTEGER,
+ author_id TEXT,
+ content TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_chat_comments_thread_id ON chat_comments(thread_id);
+ CREATE INDEX IF NOT EXISTS idx_chat_comments_message_id ON chat_comments(message_id);
+ CREATE INDEX IF NOT EXISTS idx_chat_comments_parent_id ON chat_comments(parent_id);
+ `);
+
+ // Create new_chat_messages table for live message sync
+ await db.exec(`
+ CREATE TABLE IF NOT EXISTS new_chat_messages (
+ id INTEGER PRIMARY KEY,
+ thread_id INTEGER NOT NULL,
+ role TEXT NOT NULL,
+ content JSONB NOT NULL,
+ author_id TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_new_chat_messages_thread_id ON new_chat_messages(thread_id);
+ CREATE INDEX IF NOT EXISTS idx_new_chat_messages_created_at ON new_chat_messages(created_at);
+ `);
+
const electricUrl = getElectricUrl();
// STEP 4: Create the client wrapper
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json
index cda522b61..94e44c8ec 100644
--- a/surfsense_web/messages/en.json
+++ b/surfsense_web/messages/en.json
@@ -683,6 +683,7 @@
"select_search_space": "Select Search Space",
"manage_members": "Manage members",
"search_space_settings": "Search Space settings",
+ "logs": "Logs",
"see_all_search_spaces": "See all search spaces",
"expand_sidebar": "Expand sidebar",
"collapse_sidebar": "Collapse sidebar",
@@ -692,7 +693,23 @@
"light": "Light",
"dark": "Dark",
"system": "System",
- "logout": "Logout"
+ "logout": "Logout",
+ "inbox": "Inbox",
+ "search_inbox": "Search inbox",
+ "mark_all_read": "Mark all as read",
+ "mark_as_read": "Mark as read",
+ "mentions": "Mentions",
+ "status": "Status",
+ "no_results_found": "No results found",
+ "no_mentions": "No mentions",
+ "no_mentions_hint": "You'll see mentions from others here",
+ "no_status_updates": "No status updates",
+ "no_status_updates_hint": "Document and connector updates will appear here",
+ "filter": "Filter",
+ "all": "All",
+ "unread": "Unread",
+ "connectors": "Connectors",
+ "all_connectors": "All connectors"
},
"errors": {
"something_went_wrong": "Something went wrong",
diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json
index 7f2f49cfc..3769d9dfc 100644
--- a/surfsense_web/messages/zh.json
+++ b/surfsense_web/messages/zh.json
@@ -668,6 +668,7 @@
"select_search_space": "选择搜索空间",
"manage_members": "管理成员",
"search_space_settings": "搜索空间设置",
+ "logs": "日志",
"see_all_search_spaces": "查看所有搜索空间",
"expand_sidebar": "展开侧边栏",
"collapse_sidebar": "收起侧边栏",
@@ -677,7 +678,23 @@
"light": "浅色",
"dark": "深色",
"system": "系统",
- "logout": "退出登录"
+ "logout": "退出登录",
+ "inbox": "收件箱",
+ "search_inbox": "搜索收件箱...",
+ "mark_all_read": "全部标记为已读",
+ "mark_as_read": "标记为已读",
+ "mentions": "提及",
+ "status": "状态",
+ "no_results_found": "未找到结果",
+ "no_mentions": "没有提及",
+ "no_mentions_hint": "您会在这里看到他人的提及",
+ "no_status_updates": "没有状态更新",
+ "no_status_updates_hint": "文档和连接器更新将显示在这里",
+ "filter": "筛选",
+ "all": "全部",
+ "unread": "未读",
+ "connectors": "连接器",
+ "all_connectors": "所有连接器"
},
"errors": {
"something_went_wrong": "出错了",
diff --git a/surfsense_web/package.json b/surfsense_web/package.json
index b8e628b33..3c035c13d 100644
--- a/surfsense_web/package.json
+++ b/surfsense_web/package.json
@@ -105,6 +105,7 @@
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"unist-util-visit": "^5.0.0",
+ "vaul": "^1.1.2",
"zod": "^4.2.1",
"zustand": "^5.0.9"
},
diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml
index 5e910d87a..f7928abea 100644
--- a/surfsense_web/pnpm-lock.yaml
+++ b/surfsense_web/pnpm-lock.yaml
@@ -260,6 +260,9 @@ importers:
unist-util-visit:
specifier: ^5.0.0
version: 5.0.0
+ vaul:
+ specifier: ^1.1.2
+ version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
zod:
specifier: ^4.2.1
version: 4.2.1
@@ -6384,6 +6387,12 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
+ vaul@1.1.2:
+ resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
+ peerDependencies:
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@@ -13451,6 +13460,15 @@ snapshots:
uuid@8.3.2: {}
+ vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+ dependencies:
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ transitivePeerDependencies:
+ - '@types/react'
+ - '@types/react-dom'
+
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3