diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example
index 2e10f4e36..628329917 100644
--- a/surfsense_backend/.env.example
+++ b/surfsense_backend/.env.example
@@ -32,6 +32,11 @@ ELECTRIC_DB_PASSWORD=electric_password
SCHEDULE_CHECKER_INTERVAL=5m
SECRET_KEY=SECRET
+
+# JWT Token Lifetimes (optional, defaults shown)
+# ACCESS_TOKEN_LIFETIME_SECONDS=86400 # 1 day
+# REFRESH_TOKEN_LIFETIME_SECONDS=1209600 # 2 weeks
+
NEXT_FRONTEND_URL=http://localhost:3000
# Backend URL for OAuth callbacks (optional, set when behind reverse proxy with HTTPS)
diff --git a/surfsense_backend/alembic/versions/92_add_refresh_tokens_table.py b/surfsense_backend/alembic/versions/92_add_refresh_tokens_table.py
new file mode 100644
index 000000000..c7e133ae9
--- /dev/null
+++ b/surfsense_backend/alembic/versions/92_add_refresh_tokens_table.py
@@ -0,0 +1,92 @@
+"""Add refresh_tokens table for user session management
+
+Revision ID: 92
+Revises: 91
+
+Changes:
+1. Create refresh_tokens table with columns:
+ - id (primary key)
+ - user_id (foreign key to user)
+ - token_hash (unique, indexed)
+ - expires_at (indexed)
+ - is_revoked
+ - family_id (indexed, for token rotation tracking)
+ - created_at, updated_at (timestamps)
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from sqlalchemy.dialects.postgresql import UUID
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "92"
+down_revision: str | None = "91"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Create refresh_tokens table (idempotent)."""
+ # Check if table already exists
+ connection = op.get_bind()
+ result = connection.execute(
+ sa.text(
+ "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'refresh_tokens')"
+ )
+ )
+ table_exists = result.scalar()
+
+ if not table_exists:
+ op.create_table(
+ "refresh_tokens",
+ sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column("user_id", UUID(as_uuid=True), nullable=False),
+ sa.Column("token_hash", sa.String(256), nullable=False),
+ sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=False),
+ sa.Column("is_revoked", sa.Boolean(), nullable=False, default=False),
+ sa.Column("family_id", UUID(as_uuid=True), nullable=False),
+ sa.Column(
+ "created_at",
+ sa.TIMESTAMP(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=False,
+ ),
+ sa.Column(
+ "updated_at",
+ sa.TIMESTAMP(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=False,
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ sa.ForeignKeyConstraint(
+ ["user_id"],
+ ["user.id"],
+ ondelete="CASCADE",
+ ),
+ )
+
+ # Create indexes if they don't exist
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_refresh_tokens_user_id ON refresh_tokens (user_id)"
+ )
+ op.execute(
+ "CREATE UNIQUE INDEX IF NOT EXISTS ix_refresh_tokens_token_hash ON refresh_tokens (token_hash)"
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_refresh_tokens_expires_at ON refresh_tokens (expires_at)"
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_refresh_tokens_family_id ON refresh_tokens (family_id)"
+ )
+
+
+def downgrade() -> None:
+ """Drop refresh_tokens table (idempotent)."""
+ op.execute("DROP INDEX IF EXISTS ix_refresh_tokens_family_id")
+ op.execute("DROP INDEX IF EXISTS ix_refresh_tokens_expires_at")
+ op.execute("DROP INDEX IF EXISTS ix_refresh_tokens_token_hash")
+ op.execute("DROP INDEX IF EXISTS ix_refresh_tokens_user_id")
+ op.execute("DROP TABLE IF EXISTS refresh_tokens")
diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py
index 01dd0da3d..63da4e8ad 100644
--- a/surfsense_backend/app/app.py
+++ b/surfsense_backend/app/app.py
@@ -12,6 +12,7 @@ from app.agents.new_chat.checkpointer import (
from app.config import config, initialize_llm_router
from app.db import User, create_db_and_tables, get_async_session
from app.routes import router as crud_router
+from app.routes.auth_routes import router as auth_router
from app.schemas import UserCreate, UserRead, UserUpdate
from app.tasks.surfsense_docs_indexer import seed_surfsense_docs
from app.users import SECRET, auth_backend, current_active_user, fastapi_users
@@ -111,6 +112,9 @@ app.include_router(
tags=["users"],
)
+# Include custom auth routes (refresh token, logout)
+app.include_router(auth_router)
+
if config.AUTH_TYPE == "GOOGLE":
from fastapi.responses import RedirectResponse
diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py
index 149fedd39..121e5d3b2 100644
--- a/surfsense_backend/app/config/__init__.py
+++ b/surfsense_backend/app/config/__init__.py
@@ -255,6 +255,14 @@ class Config:
# OAuth JWT
SECRET_KEY = os.getenv("SECRET_KEY")
+ # JWT Token Lifetimes
+ ACCESS_TOKEN_LIFETIME_SECONDS = int(
+ os.getenv("ACCESS_TOKEN_LIFETIME_SECONDS", str(24 * 60 * 60)) # 1 day
+ )
+ REFRESH_TOKEN_LIFETIME_SECONDS = int(
+ os.getenv("REFRESH_TOKEN_LIFETIME_SECONDS", str(14 * 24 * 60 * 60)) # 2 weeks
+ )
+
# ETL Service
ETL_SERVICE = os.getenv("ETL_SERVICE")
diff --git a/surfsense_backend/app/connectors/airtable_history.py b/surfsense_backend/app/connectors/airtable_history.py
index 64f6465fe..092485f77 100644
--- a/surfsense_backend/app/connectors/airtable_history.py
+++ b/surfsense_backend/app/connectors/airtable_history.py
@@ -71,6 +71,14 @@ class AirtableHistoryConnector:
config_data = connector.config.copy()
+ # Check if access_token exists before processing
+ raw_access_token = config_data.get("access_token")
+ if not raw_access_token:
+ raise ValueError(
+ "Airtable access token not found. "
+ "Please reconnect your Airtable account."
+ )
+
# Decrypt credentials if they are encrypted
token_encrypted = config_data.get("_token_encrypted", False)
if token_encrypted and config.SECRET_KEY:
@@ -98,6 +106,14 @@ class AirtableHistoryConnector:
f"Failed to decrypt Airtable credentials: {e!s}"
) from e
+ # Final validation after decryption
+ final_token = config_data.get("access_token")
+ if not final_token or (isinstance(final_token, str) and not final_token.strip()):
+ raise ValueError(
+ "Airtable access token is invalid or empty. "
+ "Please reconnect your Airtable account."
+ )
+
try:
self._credentials = AirtableAuthCredentialsBase.from_dict(config_data)
except Exception as e:
diff --git a/surfsense_backend/app/connectors/confluence_history.py b/surfsense_backend/app/connectors/confluence_history.py
index 9e10ffcf1..908f532db 100644
--- a/surfsense_backend/app/connectors/confluence_history.py
+++ b/surfsense_backend/app/connectors/confluence_history.py
@@ -87,6 +87,14 @@ class ConfluenceHistoryConnector:
if is_oauth:
# OAuth 2.0 authentication
+ # Check if access_token exists before processing
+ raw_access_token = config_data.get("access_token")
+ if not raw_access_token:
+ raise ValueError(
+ "Confluence access token not found. "
+ "Please reconnect your Confluence account."
+ )
+
# Decrypt credentials if they are encrypted
token_encrypted = config_data.get("_token_encrypted", False)
if token_encrypted and config.SECRET_KEY:
@@ -118,6 +126,14 @@ class ConfluenceHistoryConnector:
f"Failed to decrypt Confluence credentials: {e!s}"
) from e
+ # Final validation after decryption
+ final_token = config_data.get("access_token")
+ if not final_token or (isinstance(final_token, str) and not final_token.strip()):
+ raise ValueError(
+ "Confluence access token is invalid or empty. "
+ "Please reconnect your Confluence account."
+ )
+
try:
self._credentials = AtlassianAuthCredentialsBase.from_dict(
config_data
diff --git a/surfsense_backend/app/connectors/jira_history.py b/surfsense_backend/app/connectors/jira_history.py
index 6e04ec2a4..46a28324d 100644
--- a/surfsense_backend/app/connectors/jira_history.py
+++ b/surfsense_backend/app/connectors/jira_history.py
@@ -86,6 +86,14 @@ class JiraHistoryConnector:
if is_oauth:
# OAuth 2.0 authentication
+ # Check if access_token exists before processing
+ raw_access_token = config_data.get("access_token")
+ if not raw_access_token:
+ raise ValueError(
+ "Jira access token not found. "
+ "Please reconnect your Jira account."
+ )
+
if not config.SECRET_KEY:
raise ValueError(
"SECRET_KEY not configured but tokens are marked as encrypted"
@@ -119,6 +127,14 @@ class JiraHistoryConnector:
f"Failed to decrypt Jira credentials: {e!s}"
) from e
+ # Final validation after decryption
+ final_token = config_data.get("access_token")
+ if not final_token or (isinstance(final_token, str) and not final_token.strip()):
+ raise ValueError(
+ "Jira access token is invalid or empty. "
+ "Please reconnect your Jira account."
+ )
+
try:
self._credentials = AtlassianAuthCredentialsBase.from_dict(
config_data
diff --git a/surfsense_backend/app/connectors/linear_connector.py b/surfsense_backend/app/connectors/linear_connector.py
index b8206a40d..6500b9027 100644
--- a/surfsense_backend/app/connectors/linear_connector.py
+++ b/surfsense_backend/app/connectors/linear_connector.py
@@ -116,6 +116,14 @@ class LinearConnector:
config_data = connector.config.copy()
+ # Check if access_token exists before processing
+ raw_access_token = config_data.get("access_token")
+ if not raw_access_token:
+ raise ValueError(
+ "Linear access token not found. "
+ "Please reconnect your Linear account."
+ )
+
# Decrypt credentials if they are encrypted
token_encrypted = config_data.get("_token_encrypted", False)
if token_encrypted and config.SECRET_KEY:
@@ -143,6 +151,14 @@ class LinearConnector:
f"Failed to decrypt Linear credentials: {e!s}"
) from e
+ # Final validation after decryption
+ final_token = config_data.get("access_token")
+ if not final_token or (isinstance(final_token, str) and not final_token.strip()):
+ raise ValueError(
+ "Linear access token is invalid or empty. "
+ "Please reconnect your Linear account."
+ )
+
try:
self._credentials = LinearAuthCredentialsBase.from_dict(config_data)
except Exception as e:
diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py
index 344d83f13..55b86ad7c 100644
--- a/surfsense_backend/app/db.py
+++ b/surfsense_backend/app/db.py
@@ -1449,6 +1449,13 @@ if config.AUTH_TYPE == "GOOGLE":
display_name = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
+ # Refresh tokens for this user
+ refresh_tokens = relationship(
+ "RefreshToken",
+ back_populates="user",
+ cascade="all, delete-orphan",
+ )
+
else:
class User(SQLAlchemyBaseUserTableUUID, Base):
@@ -1514,6 +1521,43 @@ else:
display_name = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
+ # Refresh tokens for this user
+ refresh_tokens = relationship(
+ "RefreshToken",
+ back_populates="user",
+ cascade="all, delete-orphan",
+ )
+
+
+class RefreshToken(Base, TimestampMixin):
+ """
+ Stores refresh tokens for user session management.
+ Each row represents one device/session.
+ """
+
+ __tablename__ = "refresh_tokens"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ user_id = Column(
+ UUID(as_uuid=True),
+ ForeignKey("user.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+ user = relationship("User", back_populates="refresh_tokens")
+ token_hash = Column(String(256), unique=True, nullable=False, index=True)
+ expires_at = Column(TIMESTAMP(timezone=True), nullable=False, index=True)
+ is_revoked = Column(Boolean, default=False, nullable=False)
+ family_id = Column(UUID(as_uuid=True), nullable=False, index=True)
+
+ @property
+ def is_expired(self) -> bool:
+ return datetime.now(UTC) >= self.expires_at
+
+ @property
+ def is_valid(self) -> bool:
+ return not self.is_expired and not self.is_revoked
+
engine = create_async_engine(DATABASE_URL)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
diff --git a/surfsense_backend/app/prompts/__init__.py b/surfsense_backend/app/prompts/__init__.py
index 3b21cb9e1..efa31d612 100644
--- a/surfsense_backend/app/prompts/__init__.py
+++ b/surfsense_backend/app/prompts/__init__.py
@@ -104,3 +104,33 @@ SUMMARY_PROMPT = (
SUMMARY_PROMPT_TEMPLATE = PromptTemplate(
input_variables=["document"], template=SUMMARY_PROMPT
)
+
+# =============================================================================
+# Chat Title Generation Prompt
+# =============================================================================
+
+TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following conversation.
+
+
+- The title MUST be between 1 and 6 words
+- The title MUST be on a single line
+- Capture the main topic or intent of the conversation
+- Do NOT use quotes, punctuation, or formatting
+- Do NOT include words like "Chat about" or "Discussion of"
+- Return ONLY the title, nothing else
+
+
+
+{user_query}
+
+
+
+{assistant_response}
+
+
+Title:"""
+
+TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate(
+ input_variables=["user_query", "assistant_response"],
+ template=TITLE_GENERATION_PROMPT,
+)
diff --git a/surfsense_backend/app/routes/auth_routes.py b/surfsense_backend/app/routes/auth_routes.py
new file mode 100644
index 000000000..b1cbaf2a5
--- /dev/null
+++ b/surfsense_backend/app/routes/auth_routes.py
@@ -0,0 +1,93 @@
+"""Authentication routes for refresh token management."""
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+
+from app.db import User, async_session_maker
+from app.schemas.auth import (
+ LogoutAllResponse,
+ LogoutRequest,
+ LogoutResponse,
+ RefreshTokenRequest,
+ RefreshTokenResponse,
+)
+from app.users import current_active_user, get_jwt_strategy
+from app.utils.refresh_tokens import (
+ revoke_all_user_tokens,
+ revoke_refresh_token,
+ rotate_refresh_token,
+ validate_refresh_token,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/auth/jwt", tags=["auth"])
+
+
+@router.post("/refresh", response_model=RefreshTokenResponse)
+async def refresh_access_token(request: RefreshTokenRequest):
+ """
+ Exchange a valid refresh token for a new access token and refresh token.
+ Implements token rotation for security.
+ """
+ token_record = await validate_refresh_token(request.refresh_token)
+
+ if not token_record:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid or expired refresh token",
+ )
+
+ # Get user from token record
+ async with async_session_maker() as session:
+ result = await session.execute(
+ select(User).where(User.id == token_record.user_id)
+ )
+ user = result.scalars().first()
+
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="User not found",
+ )
+
+ # Generate new access token
+ strategy = get_jwt_strategy()
+ access_token = await strategy.write_token(user)
+
+ # Rotate refresh token
+ new_refresh_token = await rotate_refresh_token(token_record)
+
+ logger.info(f"Refreshed token for user {user.id}")
+
+ return RefreshTokenResponse(
+ access_token=access_token,
+ refresh_token=new_refresh_token,
+ )
+
+
+@router.post("/revoke", response_model=LogoutResponse)
+async def revoke_token(request: LogoutRequest):
+ """
+ Logout current device by revoking the provided refresh token.
+ Does not require authentication - just the refresh token.
+ """
+ revoked = await revoke_refresh_token(request.refresh_token)
+ if revoked:
+ logger.info("User logged out from current device - token revoked")
+ else:
+ logger.warning("Logout called but no matching token found to revoke")
+ return LogoutResponse()
+
+
+@router.post("/logout-all", response_model=LogoutAllResponse)
+async def logout_all_devices(user: User = Depends(current_active_user)):
+ """
+ Logout from all devices by revoking all refresh tokens for the user.
+ Requires valid access token.
+ """
+ await revoke_all_user_tokens(user.id)
+ logger.info(f"User {user.id} logged out from all devices")
+ return LogoutAllResponse()
diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py
index 42b8a821b..06e929997 100644
--- a/surfsense_backend/app/routes/new_chat_routes.py
+++ b/surfsense_backend/app/routes/new_chat_routes.py
@@ -886,30 +886,8 @@ async def append_message(
# Update thread's updated_at timestamp
thread.updated_at = datetime.now(UTC)
- # Auto-generate title from first user message if title is still default
- if thread.title == "New Chat" and role_str == "user":
- # Extract text content for title
- content = message.content
- if isinstance(content, str):
- title_text = content
- elif isinstance(content, list):
- # Find first text content
- title_text = ""
- for part in content:
- if isinstance(part, dict) and part.get("type") == "text":
- title_text = part.get("text", "")
- break
- elif isinstance(part, str):
- title_text = part
- break
- else:
- title_text = str(content)
-
- # Truncate title
- if title_text:
- thread.title = title_text[:100] + (
- "..." if len(title_text) > 100 else ""
- )
+ # Note: Title generation now happens in stream_new_chat.py after the first response
+ # using LLM to generate a descriptive title (with truncation as fallback)
await session.commit()
await session.refresh(db_message)
diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py
index e4296d501..e12ffdf38 100644
--- a/surfsense_backend/app/schemas/__init__.py
+++ b/surfsense_backend/app/schemas/__init__.py
@@ -1,3 +1,10 @@
+from .auth import (
+ LogoutAllResponse,
+ LogoutRequest,
+ LogoutResponse,
+ RefreshTokenRequest,
+ RefreshTokenResponse,
+)
from .base import IDModel, TimestampModel
from .chunks import ChunkBase, ChunkCreate, ChunkRead, ChunkUpdate
from .documents import (
@@ -119,6 +126,10 @@ __all__ = [
"LogFilter",
"LogRead",
"LogUpdate",
+ # Auth schemas
+ "LogoutAllResponse",
+ "LogoutRequest",
+ "LogoutResponse",
# Search source connector schemas
"MCPConnectorCreate",
"MCPConnectorRead",
@@ -148,6 +159,8 @@ __all__ = [
"PodcastCreate",
"PodcastRead",
"PodcastUpdate",
+ "RefreshTokenRequest",
+ "RefreshTokenResponse",
"RoleCreate",
"RoleRead",
"RoleUpdate",
diff --git a/surfsense_backend/app/schemas/auth.py b/surfsense_backend/app/schemas/auth.py
new file mode 100644
index 000000000..0d958a6d2
--- /dev/null
+++ b/surfsense_backend/app/schemas/auth.py
@@ -0,0 +1,35 @@
+"""Authentication schemas for refresh token endpoints."""
+
+from pydantic import BaseModel
+
+
+class RefreshTokenRequest(BaseModel):
+ """Request body for token refresh endpoint."""
+
+ refresh_token: str
+
+
+class RefreshTokenResponse(BaseModel):
+ """Response from token refresh endpoint."""
+
+ access_token: str
+ refresh_token: str
+ token_type: str = "bearer"
+
+
+class LogoutRequest(BaseModel):
+ """Request body for logout endpoint (current device)."""
+
+ refresh_token: str
+
+
+class LogoutResponse(BaseModel):
+ """Response from logout endpoint (current device)."""
+
+ detail: str = "Successfully logged out"
+
+
+class LogoutAllResponse(BaseModel):
+ """Response from logout all devices endpoint."""
+
+ detail: str = "Successfully logged out from all devices"
diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py
index dc3b51238..c9ca920f6 100644
--- a/surfsense_backend/app/services/chat_comments_service.py
+++ b/surfsense_backend/app/services/chat_comments_service.py
@@ -5,7 +5,7 @@ Service layer for chat comments and mentions.
from uuid import UUID
from fastapi import HTTPException
-from sqlalchemy import delete, select
+from sqlalchemy import delete, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -103,6 +103,37 @@ async def process_mentions(
return mentions_map
+async def get_comment_thread_participants(
+ session: AsyncSession,
+ parent_comment_id: int,
+ exclude_user_ids: set[UUID],
+) -> list[UUID]:
+ """
+ Get all unique authors in a comment thread (parent + replies), excluding specified users.
+
+ Args:
+ session: Database session
+ parent_comment_id: ID of the parent comment
+ exclude_user_ids: Set of user IDs to exclude (e.g., replier, mentioned users)
+
+ Returns:
+ List of user UUIDs who have participated in the thread
+ """
+ query = select(ChatComment.author_id).where(
+ or_(
+ ChatComment.id == parent_comment_id,
+ ChatComment.parent_id == parent_comment_id,
+ ),
+ ChatComment.author_id.isnot(None),
+ )
+
+ if exclude_user_ids:
+ query = query.where(ChatComment.author_id.notin_(list(exclude_user_ids)))
+
+ result = await session.execute(query.distinct())
+ return [row[0] for row in result.fetchall()]
+
+
async def get_comments_for_message(
session: AsyncSession,
message_id: int,
@@ -436,6 +467,31 @@ async def create_reply(
search_space_id=search_space_id,
)
+ # Notify thread participants (excluding replier and mentioned users)
+ mentioned_user_ids = set(mentions_map.keys())
+ exclude_ids = {user.id} | mentioned_user_ids
+ participants = await get_comment_thread_participants(
+ session, comment_id, exclude_ids
+ )
+ for participant_id in participants:
+ if participant_id in mentioned_user_ids:
+ continue
+ await NotificationService.comment_reply.notify_comment_reply(
+ session=session,
+ user_id=participant_id,
+ reply_id=reply.id,
+ parent_comment_id=comment_id,
+ message_id=parent_comment.message_id,
+ thread_id=thread.id,
+ thread_title=thread.title or "Untitled thread",
+ author_id=str(user.id),
+ author_name=author_name,
+ author_avatar_url=user.avatar_url,
+ author_email=user.email,
+ content_preview=content_preview[:200],
+ search_space_id=search_space_id,
+ )
+
author = AuthorResponse(
id=user.id,
display_name=user.display_name,
diff --git a/surfsense_backend/app/services/new_streaming_service.py b/surfsense_backend/app/services/new_streaming_service.py
index 05dd2d4dd..57fbc9663 100644
--- a/surfsense_backend/app/services/new_streaming_service.py
+++ b/surfsense_backend/app/services/new_streaming_service.py
@@ -479,6 +479,31 @@ class VercelStreamingService:
},
)
+ def format_thread_title_update(self, thread_id: int, title: str) -> str:
+ """
+ Format a thread title update notification (SurfSense specific).
+
+ This is sent after the first response in a thread to update the
+ auto-generated title based on the conversation content.
+
+ Args:
+ thread_id: The ID of the thread being updated
+ title: The new title for the thread
+
+ Returns:
+ str: SSE formatted thread title update data part
+
+ Example output:
+ data: {"type":"data-thread-title-update","data":{"threadId":123,"title":"New Title"}}
+ """
+ return self.format_data(
+ "thread-title-update",
+ {
+ "threadId": thread_id,
+ "title": title,
+ },
+ )
+
# =========================================================================
# Error Part
# =========================================================================
diff --git a/surfsense_backend/app/services/notification_service.py b/surfsense_backend/app/services/notification_service.py
index 1788d05e1..a759f3536 100644
--- a/surfsense_backend/app/services/notification_service.py
+++ b/surfsense_backend/app/services/notification_service.py
@@ -861,6 +861,98 @@ class MentionNotificationHandler(BaseNotificationHandler):
raise
+class CommentReplyNotificationHandler(BaseNotificationHandler):
+ """Handler for comment reply notifications."""
+
+ def __init__(self):
+ super().__init__("comment_reply")
+
+ async def find_notification_by_reply(
+ self,
+ session: AsyncSession,
+ reply_id: int,
+ user_id: UUID,
+ ) -> Notification | None:
+ query = select(Notification).where(
+ Notification.type == self.notification_type,
+ Notification.user_id == user_id,
+ Notification.notification_metadata["reply_id"].astext == str(reply_id),
+ )
+ result = await session.execute(query)
+ return result.scalar_one_or_none()
+
+ async def notify_comment_reply(
+ self,
+ session: AsyncSession,
+ user_id: UUID,
+ reply_id: int,
+ parent_comment_id: int,
+ message_id: int,
+ thread_id: int,
+ thread_title: str,
+ author_id: str,
+ author_name: str,
+ author_avatar_url: str | None,
+ author_email: str,
+ content_preview: str,
+ search_space_id: int,
+ ) -> Notification:
+ existing = await self.find_notification_by_reply(session, reply_id, user_id)
+ if existing:
+ logger.info(
+ f"Notification already exists for reply {reply_id} to user {user_id}"
+ )
+ return existing
+
+ title = f"{author_name} replied in a thread"
+ message = content_preview[:100] + ("..." if len(content_preview) > 100 else "")
+
+ metadata = {
+ "reply_id": reply_id,
+ "parent_comment_id": parent_comment_id,
+ "message_id": message_id,
+ "thread_id": thread_id,
+ "thread_title": thread_title,
+ "author_id": author_id,
+ "author_name": author_name,
+ "author_avatar_url": author_avatar_url,
+ "author_email": author_email,
+ "content_preview": content_preview[:200],
+ }
+
+ try:
+ notification = Notification(
+ user_id=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 comment_reply notification {notification.id} for user {user_id}"
+ )
+ return notification
+ except Exception as e:
+ await session.rollback()
+ if (
+ "duplicate key" in str(e).lower()
+ or "unique constraint" in str(e).lower()
+ ):
+ logger.warning(
+ f"Duplicate notification for reply {reply_id} to user {user_id}"
+ )
+ existing = await self.find_notification_by_reply(
+ session, reply_id, user_id
+ )
+ if existing:
+ return existing
+ raise
+
+
class PageLimitNotificationHandler(BaseNotificationHandler):
"""Handler for page limit exceeded notifications."""
@@ -959,6 +1051,7 @@ class NotificationService:
connector_indexing = ConnectorIndexingNotificationHandler()
document_processing = DocumentProcessingNotificationHandler()
mention = MentionNotificationHandler()
+ comment_reply = CommentReplyNotificationHandler()
page_limit = PageLimitNotificationHandler()
@staticmethod
diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py
index 2125dd8ce..4da316240 100644
--- a/surfsense_backend/app/services/public_chat_service.py
+++ b/surfsense_backend/app/services/public_chat_service.py
@@ -366,11 +366,14 @@ async def list_snapshots_for_thread(
if not thread:
raise HTTPException(status_code=404, detail="Thread not found")
- if thread.created_by_id != user.id:
- raise HTTPException(
- status_code=403,
- detail="Only the creator can view snapshots",
- )
+ # Check permission to view public share links
+ await check_permission(
+ session,
+ user,
+ thread.search_space_id,
+ Permission.PUBLIC_SHARING_VIEW.value,
+ "You don't have permission to view public share links",
+ )
result = await session.execute(
select(PublicChatSnapshot)
diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py
index 688777203..a9751e5d1 100644
--- a/surfsense_backend/app/tasks/chat/stream_new_chat.py
+++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py
@@ -32,6 +32,7 @@ from app.services.chat_session_state_service import (
clear_ai_responding,
set_ai_responding,
)
+from app.prompts import TITLE_GENERATION_PROMPT_TEMPLATE
from app.services.connector_service import ConnectorService
from app.services.new_streaming_service import VercelStreamingService
from app.utils.content_utils import bootstrap_history_from_db
@@ -1208,6 +1209,59 @@ async def stream_new_chat(
if completion_event:
yield completion_event
+ # Generate LLM title for new chats after first response
+ # Check if this is the first assistant response by counting existing assistant messages
+ from app.db import NewChatMessage, NewChatThread
+ from sqlalchemy import func
+
+ assistant_count_result = await session.execute(
+ select(func.count(NewChatMessage.id)).filter(
+ NewChatMessage.thread_id == chat_id,
+ NewChatMessage.role == "assistant",
+ )
+ )
+ assistant_message_count = assistant_count_result.scalar() or 0
+
+ # Only generate title on the first response (no prior assistant messages)
+ if assistant_message_count == 0:
+ generated_title = None
+ try:
+ # Generate title using the same LLM
+ title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm
+ # Truncate inputs to avoid context length issues
+ truncated_query = user_query[:500]
+ truncated_response = accumulated_text[:1000]
+ title_result = await title_chain.ainvoke({
+ "user_query": truncated_query,
+ "assistant_response": truncated_response,
+ })
+
+ # Extract and clean the title
+ if title_result and hasattr(title_result, "content"):
+ raw_title = title_result.content.strip()
+ # Validate the title (reasonable length)
+ if raw_title and len(raw_title) <= 100:
+ # Remove any quotes or extra formatting
+ generated_title = raw_title.strip('"\'')
+ except Exception:
+ generated_title = None
+
+ # Only update if LLM succeeded (keep truncated prompt title as fallback)
+ if generated_title:
+ # Fetch thread and update title
+ thread_result = await session.execute(
+ select(NewChatThread).filter(NewChatThread.id == chat_id)
+ )
+ thread = thread_result.scalars().first()
+ if thread:
+ thread.title = generated_title
+ await session.commit()
+
+ # Notify frontend of the title update
+ yield streaming_service.format_thread_title_update(
+ chat_id, generated_title
+ )
+
# Finish the step and message
yield streaming_service.format_finish_step()
yield streaming_service.format_finish()
diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py
index 4be2fe525..696cdf25e 100644
--- a/surfsense_backend/app/users.py
+++ b/surfsense_backend/app/users.py
@@ -23,17 +23,20 @@ from app.db import (
get_default_roles_config,
get_user_db,
)
+from app.utils.refresh_tokens import create_refresh_token
logger = logging.getLogger(__name__)
class BearerResponse(BaseModel):
access_token: str
+ refresh_token: str
token_type: str
SECRET = config.SECRET_KEY
+
if config.AUTH_TYPE == "GOOGLE":
from httpx_oauth.clients.google import GoogleOAuth2
@@ -183,7 +186,10 @@ async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db
def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]:
- return JWTStrategy(secret=SECRET, lifetime_seconds=3600 * 24)
+ return JWTStrategy(
+ secret=SECRET,
+ lifetime_seconds=config.ACCESS_TOKEN_LIFETIME_SECONDS,
+ )
# # COOKIE AUTH | Uncomment if you want to use cookie auth.
@@ -209,9 +215,30 @@ def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]:
# BEARER AUTH CODE.
class CustomBearerTransport(BearerTransport):
async def get_login_response(self, token: str) -> Response:
- bearer_response = BearerResponse(access_token=token, token_type="bearer")
- redirect_url = f"{config.NEXT_FRONTEND_URL}/auth/callback?token={bearer_response.access_token}"
+ import jwt
+
+ # Decode JWT to get user_id for refresh token creation
+ try:
+ payload = jwt.decode(token, SECRET, algorithms=["HS256"], options={"verify_aud": False})
+ user_id = uuid.UUID(payload.get("sub"))
+ refresh_token = await create_refresh_token(user_id)
+ except Exception as e:
+ logger.error(f"Failed to create refresh token: {e}")
+ # Fall back to response without refresh token
+ refresh_token = ""
+
+ bearer_response = BearerResponse(
+ access_token=token,
+ refresh_token=refresh_token,
+ token_type="bearer",
+ )
+
if config.AUTH_TYPE == "GOOGLE":
+ redirect_url = (
+ f"{config.NEXT_FRONTEND_URL}/auth/callback"
+ f"?token={bearer_response.access_token}"
+ f"&refresh_token={bearer_response.refresh_token}"
+ )
return RedirectResponse(redirect_url, status_code=302)
else:
return JSONResponse(bearer_response.model_dump())
diff --git a/surfsense_backend/app/utils/refresh_tokens.py b/surfsense_backend/app/utils/refresh_tokens.py
new file mode 100644
index 000000000..8c0312ba8
--- /dev/null
+++ b/surfsense_backend/app/utils/refresh_tokens.py
@@ -0,0 +1,153 @@
+"""Utilities for managing refresh tokens."""
+
+import hashlib
+import logging
+import secrets
+import uuid
+from datetime import UTC, datetime, timedelta
+
+from sqlalchemy import select, update
+
+from app.config import config
+from app.db import RefreshToken, async_session_maker
+
+logger = logging.getLogger(__name__)
+
+
+def generate_refresh_token() -> str:
+ """Generate a cryptographically secure refresh token."""
+ return secrets.token_urlsafe(32)
+
+
+def hash_token(token: str) -> str:
+ """Hash a token for secure storage."""
+ return hashlib.sha256(token.encode()).hexdigest()
+
+
+async def create_refresh_token(
+ user_id: uuid.UUID,
+ family_id: uuid.UUID | None = None,
+) -> str:
+ """
+ Create and store a new refresh token for a user.
+
+ Args:
+ user_id: The user's ID
+ family_id: Optional family ID for token rotation
+
+ Returns:
+ The plaintext refresh token
+ """
+ token = generate_refresh_token()
+ token_hash = hash_token(token)
+ expires_at = datetime.now(UTC) + timedelta(
+ seconds=config.REFRESH_TOKEN_LIFETIME_SECONDS
+ )
+
+ if family_id is None:
+ family_id = uuid.uuid4()
+
+ async with async_session_maker() as session:
+ refresh_token = RefreshToken(
+ user_id=user_id,
+ token_hash=token_hash,
+ expires_at=expires_at,
+ family_id=family_id,
+ )
+ session.add(refresh_token)
+ await session.commit()
+
+ return token
+
+
+async def validate_refresh_token(token: str) -> RefreshToken | None:
+ """
+ Validate a refresh token. Handles reuse detection.
+
+ Args:
+ token: The plaintext refresh token
+
+ Returns:
+ RefreshToken if valid, None otherwise
+ """
+ token_hash = hash_token(token)
+
+ async with async_session_maker() as session:
+ result = await session.execute(
+ select(RefreshToken).where(RefreshToken.token_hash == token_hash)
+ )
+ refresh_token = result.scalars().first()
+
+ if not refresh_token:
+ return None
+
+ # Reuse detection: revoked token used while family has active tokens
+ if refresh_token.is_revoked:
+ active = await session.execute(
+ select(RefreshToken).where(
+ RefreshToken.family_id == refresh_token.family_id,
+ RefreshToken.is_revoked == False, # noqa: E712
+ RefreshToken.expires_at > datetime.now(UTC),
+ )
+ )
+ if active.scalars().first():
+ # Revoke entire family
+ await session.execute(
+ update(RefreshToken)
+ .where(RefreshToken.family_id == refresh_token.family_id)
+ .values(is_revoked=True)
+ )
+ await session.commit()
+ logger.warning(f"Token reuse detected for user {refresh_token.user_id}")
+ return None
+
+ if refresh_token.is_expired:
+ return None
+
+ return refresh_token
+
+
+async def rotate_refresh_token(old_token: RefreshToken) -> str:
+ """Revoke old token and create new one in same family."""
+ async with async_session_maker() as session:
+ await session.execute(
+ update(RefreshToken)
+ .where(RefreshToken.id == old_token.id)
+ .values(is_revoked=True)
+ )
+ await session.commit()
+
+ return await create_refresh_token(old_token.user_id, old_token.family_id)
+
+
+async def revoke_refresh_token(token: str) -> bool:
+ """
+ Revoke a single refresh token by its plaintext value.
+
+ Args:
+ token: The plaintext refresh token
+
+ Returns:
+ True if token was found and revoked, False otherwise
+ """
+ token_hash = hash_token(token)
+
+ async with async_session_maker() as session:
+ result = await session.execute(
+ update(RefreshToken)
+ .where(RefreshToken.token_hash == token_hash)
+ .values(is_revoked=True)
+ )
+ await session.commit()
+ return result.rowcount > 0
+
+
+async def revoke_all_user_tokens(user_id: uuid.UUID) -> None:
+ """Revoke all refresh tokens for a user (logout all devices)."""
+ async with async_session_maker() as session:
+ await session.execute(
+ update(RefreshToken)
+ .where(RefreshToken.user_id == user_id)
+ .values(is_revoked=True)
+ )
+ await session.commit()
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 9b462fcbc..1a535539d 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
@@ -437,7 +437,10 @@ export default function NewChatPage() {
let isNewThread = false;
if (!currentThreadId) {
try {
- const newThread = await createThread(searchSpaceId, "New Chat");
+ // Create thread with truncated prompt as initial title
+ const initialTitle =
+ userQuery.trim().slice(0, 100) + (userQuery.trim().length > 100 ? "..." : "");
+ const newThread = await createThread(searchSpaceId, initialTitle);
currentThreadId = newThread.id;
setThreadId(currentThreadId);
// Set currentThread so ChatHeader can show share button immediately
@@ -827,6 +830,26 @@ export default function NewChatPage() {
break;
}
+ case "data-thread-title-update": {
+ // Handle thread title update from LLM-generated title
+ const titleData = parsed.data as { threadId: number; title: string };
+ if (titleData?.title && titleData?.threadId === currentThreadId) {
+ // Update current thread state with new title
+ setCurrentThread((prev) =>
+ prev ? { ...prev, title: titleData.title } : prev
+ );
+ // Invalidate thread list to refresh sidebar
+ queryClient.invalidateQueries({
+ queryKey: ["threads", String(searchSpaceId)],
+ });
+ // Invalidate thread detail for breadcrumb update
+ queryClient.invalidateQueries({
+ queryKey: ["threads", String(searchSpaceId), "detail", String(titleData.threadId)],
+ });
+ }
+ break;
+ }
+
case "error":
throw new Error(parsed.errorText || "Server error");
}
diff --git a/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx b/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx
index b7040b4e3..3424113a9 100644
--- a/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx
+++ b/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx
@@ -5,6 +5,7 @@ import { ArrowLeft, ChevronRight, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
+import { APP_VERSION } from "@/lib/env-config";
import { cn } from "@/lib/utils";
export interface SettingsNavItem {
@@ -148,6 +149,11 @@ export function UserSettingsSidebar({
);
})}
+
+ {/* Version display */}
+
>
);
diff --git a/surfsense_web/components/TokenHandler.tsx b/surfsense_web/components/TokenHandler.tsx
index e3295df7c..230cda81a 100644
--- a/surfsense_web/components/TokenHandler.tsx
+++ b/surfsense_web/components/TokenHandler.tsx
@@ -3,7 +3,7 @@
import { useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
-import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
+import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils";
import { trackLoginSuccess } from "@/lib/posthog/events";
interface TokenHandlerProps {
@@ -35,8 +35,9 @@ const TokenHandler = ({
// Only run on client-side
if (typeof window === "undefined") return;
- // Get token from URL parameters
+ // Get tokens from URL parameters
const token = searchParams.get(tokenParamName);
+ const refreshToken = searchParams.get("refresh_token");
if (token) {
try {
@@ -50,10 +51,15 @@ const TokenHandler = ({
// Clear the flag for future logins
sessionStorage.removeItem("login_success_tracked");
- // Store token in localStorage using both methods for compatibility
+ // Store access token in localStorage using both methods for compatibility
localStorage.setItem(storageKey, token);
setBearerToken(token);
+ // Store refresh token if provided
+ if (refreshToken) {
+ setRefreshToken(refreshToken);
+ }
+
// Check if there's a saved redirect path from before the auth flow
const savedRedirectPath = getAndClearRedirectPath();
diff --git a/surfsense_web/components/UserDropdown.tsx b/surfsense_web/components/UserDropdown.tsx
index 3dac745cf..233a41a1f 100644
--- a/surfsense_web/components/UserDropdown.tsx
+++ b/surfsense_web/components/UserDropdown.tsx
@@ -1,7 +1,8 @@
"use client";
-import { BadgeCheck, LogOut } from "lucide-react";
+import { BadgeCheck, Loader2, LogOut } from "lucide-react";
import { useRouter } from "next/navigation";
+import { useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
@@ -13,6 +14,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { logout } from "@/lib/auth-utils";
import { cleanupElectric } from "@/lib/electric/client";
import { resetUser, trackLogout } from "@/lib/posthog/events";
@@ -26,8 +28,11 @@ export function UserDropdown({
};
}) {
const router = useRouter();
+ const [isLoggingOut, setIsLoggingOut] = useState(false);
const handleLogout = async () => {
+ if (isLoggingOut) return;
+ setIsLoggingOut(true);
try {
// Track logout event and reset PostHog identity
trackLogout();
@@ -41,15 +46,17 @@ export function UserDropdown({
console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err);
}
+ // Revoke refresh token on server and clear all tokens from localStorage
+ await logout();
+
if (typeof window !== "undefined") {
- localStorage.removeItem("surfsense_bearer_token");
window.location.href = "/";
}
} catch (error) {
console.error("Error during logout:", error);
- // Optionally, provide user feedback
+ // Even if there's an error, try to clear tokens and redirect
+ await logout();
if (typeof window !== "undefined") {
- localStorage.removeItem("surfsense_bearer_token");
window.location.href = "/";
}
}
@@ -85,9 +92,17 @@ export function UserDropdown({
-
-
- Log out
+
+ {isLoggingOut ? (
+
+ ) : (
+
+ )}
+ {isLoggingOut ? "Logging out..." : "Log out"}
diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx
index 4fd2446c3..5cdd287de 100644
--- a/surfsense_web/components/assistant-ui/assistant-message.tsx
+++ b/surfsense_web/components/assistant-ui/assistant-message.tsx
@@ -4,20 +4,19 @@ import {
ErrorPrimitive,
MessagePrimitive,
useAssistantState,
+ useMessage,
} from "@assistant-ui/react";
-import { useAtom, useAtomValue, useSetAtom } from "jotai";
+import { useAtom, useAtomValue } from "jotai";
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
import type { FC } from "react";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import {
addingCommentToMessageIdAtom,
- clearTargetCommentIdAtom,
commentsCollapsedAtom,
commentsEnabledAtom,
targetCommentIdAtom,
} from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
-import { BranchPicker } from "@/components/assistant-ui/branch-picker";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import {
ThinkingStepsContext,
@@ -84,7 +83,6 @@ const AssistantMessageInner: FC = () => {
>
@@ -126,7 +124,6 @@ export const AssistantMessage: FC = () => {
// Target comment navigation - read target from global atom
const targetCommentId = useAtomValue(targetCommentIdAtom);
- const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
// Check if target comment belongs to this message (including replies)
const hasTargetComment = useMemo(() => {
@@ -263,6 +260,8 @@ export const AssistantMessage: FC = () => {
};
const AssistantActionBar: FC = () => {
+ const { isLast } = useMessage();
+
return (
{
-
-
-
-
-
+ {/* Only allow regenerating the last assistant message */}
+ {isLast && (
+
+
+
+
+
+ )}
);
};
diff --git a/surfsense_web/components/assistant-ui/branch-picker.tsx b/surfsense_web/components/assistant-ui/branch-picker.tsx
deleted file mode 100644
index ee4addd2a..000000000
--- a/surfsense_web/components/assistant-ui/branch-picker.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { BranchPickerPrimitive } from "@assistant-ui/react";
-import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
-import type { FC } from "react";
-import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
-import { cn } from "@/lib/utils";
-
-export const BranchPicker: FC = ({ className, ...rest }) => {
- return (
-
-
-
-
-
-
-
- /
-
-
-
-
-
-
-
- );
-};
diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx
index 896b8c748..1ae8aef3c 100644
--- a/surfsense_web/components/assistant-ui/user-message.tsx
+++ b/surfsense_web/components/assistant-ui/user-message.tsx
@@ -4,7 +4,6 @@ import { FileText, PencilIcon } from "lucide-react";
import { type FC, useState } from "react";
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
import { UserMessageAttachments } from "@/components/assistant-ui/attachment";
-import { BranchPicker } from "@/components/assistant-ui/branch-picker";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
interface AuthorMetadata {
@@ -95,24 +94,47 @@ export const UserMessage: FC = () => {
)}
-
-
);
};
const UserActionBar: FC = () => {
+ const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
+
+ // Get current message ID
+ const currentMessageId = useAssistantState(({ message }) => message?.id);
+
+ // Find the last user message ID in the thread (computed once, memoized by selector)
+ const lastUserMessageId = useAssistantState(({ thread }) => {
+ const messages = thread.messages;
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (messages[i].role === "user") {
+ return messages[i].id;
+ }
+ }
+ return null;
+ });
+
+ // Simple comparison - no iteration needed per message
+ const isLastUserMessage = currentMessageId === lastUserMessageId;
+
+ // Show edit button only on the last user message and when thread is not running
+ const canEdit = isLastUserMessage && !isThreadRunning;
+
return (
-
-
-
-
-
+ {/* Only allow editing the last user message */}
+ {canEdit && (
+
+
+
+
+
+ )}
);
};
diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx
index 96bd0ef30..5c6399ce0 100644
--- a/surfsense_web/components/dashboard-breadcrumb.tsx
+++ b/surfsense_web/components/dashboard-breadcrumb.tsx
@@ -14,6 +14,7 @@ import {
} from "@/components/ui/breadcrumb";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils";
+import { getThreadFull } from "@/lib/chat/thread-persistence";
import { cacheKeys } from "@/lib/query-client/cache-keys";
interface BreadcrumbItemInterface {
@@ -34,6 +35,16 @@ export function DashboardBreadcrumb() {
enabled: !!searchSpaceId,
});
+ // Extract chat thread ID from pathname for chat pages
+ const chatThreadId = segments[2] === "new-chat" && segments[3] ? segments[3] : null;
+
+ // Fetch thread details when on a chat page with a thread ID
+ const { data: threadData } = useQuery({
+ queryKey: ["threads", searchSpaceId, "detail", chatThreadId],
+ queryFn: () => getThreadFull(Number(chatThreadId)),
+ enabled: !!chatThreadId && !!searchSpaceId,
+ });
+
// State to store document title for editor breadcrumb
const [documentTitle, setDocumentTitle] = useState(null);
@@ -144,10 +155,11 @@ export function DashboardBreadcrumb() {
}
// Handle new-chat sub-sections (thread IDs)
- // Don't show thread ID in breadcrumb - users identify chats by content, not by ID
+ // Show the chat title if available, otherwise fall back to "Chat"
if (section === "new-chat") {
+ const chatLabel = threadData?.title || t("chat") || "Chat";
breadcrumbs.push({
- label: t("chat") || "Chat",
+ label: chatLabel,
});
return breadcrumbs;
}
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index b87cc4883..66d2f419a 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, useSetAtom } from "jotai";
-import { AlertTriangle, Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
+import { AlertTriangle, Inbox, LogOut, PencilIcon, SquareLibrary, Trash2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@@ -21,10 +21,12 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
+import { logout } from "@/lib/auth-utils";
import { cleanupElectric } from "@/lib/electric/client";
import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
@@ -109,7 +111,6 @@ export function LayoutDataProvider({
// This ensures each tab has independent pagination and data loading
const userId = user?.id ? String(user.id) : null;
- // Mentions: Only fetch "new_mention" type notifications
const {
inboxItems: mentionItems,
unreadCount: mentionUnreadCount,
@@ -121,11 +122,9 @@ export function LayoutDataProvider({
markAllAsRead: markAllMentionsAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention");
- // Status: Fetch all types (will be filtered client-side to status types)
- // We pass null to get all, then InboxSidebar filters to status types
const {
inboxItems: statusItems,
- unreadCount: statusUnreadCount,
+ unreadCount: allUnreadCount,
loading: statusLoading,
loadingMore: statusLoadingMore,
hasMore: statusHasMore,
@@ -134,8 +133,8 @@ export function LayoutDataProvider({
markAllAsRead: markAllStatusAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, null);
- // Combined unread count for nav badge (mentions take priority for visibility)
- const totalUnreadCount = mentionUnreadCount + statusUnreadCount;
+ const totalUnreadCount = allUnreadCount;
+ const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount);
// Track seen notification IDs to detect new page_limit_exceeded notifications
const seenPageLimitNotifications = useRef>(new Set());
@@ -207,6 +206,12 @@ export function LayoutDataProvider({
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
const [isDeletingChat, setIsDeletingChat] = useState(false);
+ // Rename dialog state
+ const [showRenameChatDialog, setShowRenameChatDialog] = useState(false);
+ const [chatToRename, setChatToRename] = useState<{ id: number; name: string } | null>(null);
+ const [newChatTitle, setNewChatTitle] = useState("");
+ const [isRenamingChat, setIsRenamingChat] = useState(false);
+
// Delete/Leave search space dialog state
const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false);
const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false);
@@ -421,6 +426,12 @@ export function LayoutDataProvider({
setShowDeleteChatDialog(true);
}, []);
+ const handleChatRename = useCallback((chat: ChatItem) => {
+ setChatToRename({ id: chat.id, name: chat.name });
+ setNewChatTitle(chat.name);
+ setShowRenameChatDialog(true);
+ }, []);
+
const handleChatArchive = useCallback(
async (chat: ChatItem) => {
const newArchivedState = !chat.archived;
@@ -464,12 +475,15 @@ export function LayoutDataProvider({
console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err);
}
+ // Revoke refresh token on server and clear all tokens from localStorage
+ await logout();
+
if (typeof window !== "undefined") {
- localStorage.removeItem("surfsense_bearer_token");
router.push("/");
}
} catch (error) {
console.error("Error during logout:", error);
+ await logout();
router.push("/");
}
}, [router]);
@@ -501,6 +515,29 @@ export function LayoutDataProvider({
}
}, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]);
+ // Rename handler
+ const confirmRenameChat = useCallback(async () => {
+ if (!chatToRename || !newChatTitle.trim()) return;
+ setIsRenamingChat(true);
+ try {
+ await updateThread(chatToRename.id, { title: newChatTitle.trim() });
+ toast.success(tSidebar("chat_renamed") || "Chat renamed");
+ queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
+ queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
+ queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
+ // Invalidate thread detail for breadcrumb update
+ queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)] });
+ } catch (error) {
+ console.error("Error renaming thread:", error);
+ toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
+ } finally {
+ setIsRenamingChat(false);
+ setShowRenameChatDialog(false);
+ setChatToRename(null);
+ setNewChatTitle("");
+ }
+ }, [chatToRename, newChatTitle, queryClient, searchSpaceId, tSidebar]);
+
// Page usage
const pageUsage = user
? {
@@ -529,6 +566,7 @@ export function LayoutDataProvider({
activeChatId={currentChatId}
onNewChat={handleNewChat}
onChatSelect={handleChatSelect}
+ onChatRename={handleChatRename}
onChatDelete={handleChatDelete}
onChatArchive={handleChatArchive}
onViewAllSharedChats={handleViewAllSharedChats}
@@ -562,7 +600,7 @@ export function LayoutDataProvider({
},
status: {
items: statusItems,
- unreadCount: statusUnreadCount,
+ unreadCount: statusOnlyUnreadCount,
loading: statusLoading,
loadingMore: statusLoadingMore,
hasMore: statusHasMore,
@@ -621,6 +659,57 @@ export function LayoutDataProvider({
+ {/* Rename Chat Dialog */}
+
+
{/* Delete Search Space Dialog */}