From d48b3680d3b5ab79619a647669069b819ce1532b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 16:24:29 +0200 Subject: [PATCH 01/57] Add chat_comments table migration --- .../versions/66_add_chat_comments_table.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 surfsense_backend/alembic/versions/66_add_chat_comments_table.py diff --git a/surfsense_backend/alembic/versions/66_add_chat_comments_table.py b/surfsense_backend/alembic/versions/66_add_chat_comments_table.py new file mode 100644 index 000000000..332ad3ca3 --- /dev/null +++ b/surfsense_backend/alembic/versions/66_add_chat_comments_table.py @@ -0,0 +1,46 @@ +"""Add chat_comments table for comments on AI responses + +Revision ID: 66 +Revises: 65 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "66" +down_revision: str | None = "65" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create chat_comments table.""" + op.execute( + """ + CREATE TABLE IF NOT EXISTS chat_comments ( + id SERIAL PRIMARY KEY, + message_id INTEGER NOT NULL REFERENCES new_chat_messages(id) ON DELETE CASCADE, + parent_id INTEGER REFERENCES chat_comments(id) ON DELETE CASCADE, + author_id UUID REFERENCES "user"(id) ON DELETE SET NULL, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + -- Create indexes + 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 INDEX IF NOT EXISTS idx_chat_comments_author_id ON chat_comments(author_id); + CREATE INDEX IF NOT EXISTS idx_chat_comments_created_at ON chat_comments(created_at); + """ + ) + + +def downgrade() -> None: + """Drop chat_comments table.""" + op.execute( + """ + DROP TABLE IF EXISTS chat_comments; + """ + ) From 266a5be38b282e2a96ff3983bcf38adccb856176 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 16:26:49 +0200 Subject: [PATCH 02/57] Add chat_comment_mentions table migration --- .../67_add_chat_comment_mentions_table.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 surfsense_backend/alembic/versions/67_add_chat_comment_mentions_table.py diff --git a/surfsense_backend/alembic/versions/67_add_chat_comment_mentions_table.py b/surfsense_backend/alembic/versions/67_add_chat_comment_mentions_table.py new file mode 100644 index 000000000..ceb8669ae --- /dev/null +++ b/surfsense_backend/alembic/versions/67_add_chat_comment_mentions_table.py @@ -0,0 +1,45 @@ +"""Add chat_comment_mentions table for @mentions in comments + +Revision ID: 67 +Revises: 66 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "67" +down_revision: str | None = "66" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create chat_comment_mentions table.""" + op.execute( + """ + CREATE TABLE IF NOT EXISTS chat_comment_mentions ( + id SERIAL PRIMARY KEY, + comment_id INTEGER NOT NULL REFERENCES chat_comments(id) ON DELETE CASCADE, + mentioned_user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (comment_id, mentioned_user_id) + ); + + -- Create indexes + CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_comment_id + ON chat_comment_mentions(comment_id); + CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_user_unread + ON chat_comment_mentions(mentioned_user_id) WHERE read = FALSE; + """ + ) + + +def downgrade() -> None: + """Drop chat_comment_mentions table.""" + op.execute( + """ + DROP TABLE IF EXISTS chat_comment_mentions; + """ + ) From b06b3baaea60efc2b05308e5d8dce33f5aaadb16 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 16:34:03 +0200 Subject: [PATCH 03/57] Add ChatComment model to db.py --- surfsense_backend/app/db.py | 53 +++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index fd2100400..c31909860 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -424,6 +424,59 @@ class NewChatMessage(BaseModel, TimestampMixin): # Relationships thread = relationship("NewChatThread", back_populates="messages") author = relationship("User") + comments = relationship( + "ChatComment", + back_populates="message", + cascade="all, delete-orphan", + ) + + +class ChatComment(BaseModel, TimestampMixin): + """ + Comment model for comments on AI chat responses. + Supports one level of nesting (replies to comments, but no replies to replies). + """ + + __tablename__ = "chat_comments" + + message_id = Column( + Integer, + ForeignKey("new_chat_messages.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + parent_id = Column( + Integer, + ForeignKey("chat_comments.id", ondelete="CASCADE"), + nullable=True, + index=True, + ) + author_id = Column( + UUID(as_uuid=True), + ForeignKey("user.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + content = Column(Text, nullable=False) + updated_at = Column( + TIMESTAMP(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + index=True, + ) + + # Relationships + message = relationship("NewChatMessage", back_populates="comments") + author = relationship("User") + parent = relationship( + "ChatComment", remote_side="ChatComment.id", backref="replies" + ) + mentions = relationship( + "ChatCommentMention", + back_populates="comment", + cascade="all, delete-orphan", + ) class Document(BaseModel, TimestampMixin): From ee68fb86d26c37143c829123e7d01c7e9f216f2c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 16:37:46 +0200 Subject: [PATCH 04/57] Add ChatCommentMention model to db.py --- surfsense_backend/app/db.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index c31909860..f59dc48e9 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -479,6 +479,32 @@ class ChatComment(BaseModel, TimestampMixin): ) +class ChatCommentMention(BaseModel, TimestampMixin): + """ + Tracks @mentions in chat comments for notification purposes. + """ + + __tablename__ = "chat_comment_mentions" + + comment_id = Column( + Integer, + ForeignKey("chat_comments.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + mentioned_user_id = Column( + UUID(as_uuid=True), + ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + read = Column(Boolean, nullable=False, default=False) + + # Relationships + comment = relationship("ChatComment", back_populates="mentions") + mentioned_user = relationship("User") + + class Document(BaseModel, TimestampMixin): __tablename__ = "documents" From b7a167dffe795f221bb7076289dbc9b1bf710b9e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 16:39:24 +0200 Subject: [PATCH 05/57] Add comment permissions to Permission enum --- surfsense_backend/app/db.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index f59dc48e9..1baa13a7f 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -152,6 +152,11 @@ class Permission(str, Enum): CHATS_UPDATE = "chats:update" CHATS_DELETE = "chats:delete" + # Comments + COMMENTS_CREATE = "comments:create" + COMMENTS_READ = "comments:read" + COMMENTS_DELETE = "comments:delete" + # LLM Configs LLM_CONFIGS_CREATE = "llm_configs:create" LLM_CONFIGS_READ = "llm_configs:read" From c14776faad77b375401b2d9fa08f0dfd0b7fcdca Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 16:42:09 +0200 Subject: [PATCH 06/57] Add comment permissions to DEFAULT_ROLE_PERMISSIONS --- surfsense_backend/app/db.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 1baa13a7f..759451f5f 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -214,6 +214,10 @@ DEFAULT_ROLE_PERMISSIONS = { Permission.CHATS_READ.value, Permission.CHATS_UPDATE.value, Permission.CHATS_DELETE.value, + # Comments + Permission.COMMENTS_CREATE.value, + Permission.COMMENTS_READ.value, + Permission.COMMENTS_DELETE.value, # LLM Configs Permission.LLM_CONFIGS_CREATE.value, Permission.LLM_CONFIGS_READ.value, @@ -257,6 +261,9 @@ DEFAULT_ROLE_PERMISSIONS = { Permission.CHATS_READ.value, Permission.CHATS_UPDATE.value, Permission.CHATS_DELETE.value, + # Comments (no delete) + Permission.COMMENTS_CREATE.value, + Permission.COMMENTS_READ.value, # LLM Configs (read only) Permission.LLM_CONFIGS_READ.value, Permission.LLM_CONFIGS_CREATE.value, @@ -284,6 +291,9 @@ DEFAULT_ROLE_PERMISSIONS = { Permission.DOCUMENTS_READ.value, # Chats (read only) Permission.CHATS_READ.value, + # Comments (no delete) + Permission.COMMENTS_CREATE.value, + Permission.COMMENTS_READ.value, # LLM Configs (read only) Permission.LLM_CONFIGS_READ.value, # Podcasts (read only) From 43939d554cc7fe6a19533aad6863e6afce70359e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 16:48:30 +0200 Subject: [PATCH 07/57] Add Pydantic schemas for comments --- surfsense_backend/app/schemas/comments.py | 130 ++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 surfsense_backend/app/schemas/comments.py diff --git a/surfsense_backend/app/schemas/comments.py b/surfsense_backend/app/schemas/comments.py new file mode 100644 index 000000000..1cc7de0a2 --- /dev/null +++ b/surfsense_backend/app/schemas/comments.py @@ -0,0 +1,130 @@ +""" +Pydantic schemas for chat comments and mentions. +""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +# ============================================================================= +# Request Schemas +# ============================================================================= + + +class CommentCreateRequest(BaseModel): + """Schema for creating a comment or reply.""" + + content: str = Field(..., min_length=1, max_length=5000) + + +class CommentUpdateRequest(BaseModel): + """Schema for updating a comment.""" + + content: str = Field(..., min_length=1, max_length=5000) + + +# ============================================================================= +# Author Schema +# ============================================================================= + + +class AuthorResponse(BaseModel): + """Author information for comments.""" + + id: UUID + display_name: str | None = None + avatar_url: str | None = None + email: str + + model_config = ConfigDict(from_attributes=True) + + +# ============================================================================= +# Comment Schemas +# ============================================================================= + + +class CommentReplyResponse(BaseModel): + """Schema for a comment reply (no nested replies).""" + + id: int + content: str + content_rendered: str + author: AuthorResponse | None = None + created_at: datetime + updated_at: datetime + is_edited: bool + can_edit: bool + can_delete: bool + + model_config = ConfigDict(from_attributes=True) + + +class CommentResponse(BaseModel): + """Schema for a top-level comment with replies.""" + + id: int + message_id: int + content: str + content_rendered: str + author: AuthorResponse | None = None + created_at: datetime + updated_at: datetime + is_edited: bool + can_edit: bool + can_delete: bool + reply_count: int + replies: list[CommentReplyResponse] = [] + + model_config = ConfigDict(from_attributes=True) + + +class CommentListResponse(BaseModel): + """Response for listing comments on a message.""" + + comments: list[CommentResponse] + total_count: int + + +# ============================================================================= +# Mention Schemas +# ============================================================================= + + +class MentionContextResponse(BaseModel): + """Context information for where a mention occurred.""" + + thread_id: int + thread_title: str + message_id: int + search_space_id: int + search_space_name: str + + +class MentionCommentResponse(BaseModel): + """Abbreviated comment info for mention display.""" + + id: int + content_preview: str + author: AuthorResponse | None = None + created_at: datetime + + +class MentionResponse(BaseModel): + """Schema for a mention notification.""" + + id: int + read: bool + created_at: datetime + comment: MentionCommentResponse + context: MentionContextResponse + + model_config = ConfigDict(from_attributes=True) + + +class MentionListResponse(BaseModel): + """Response for listing user's mentions.""" + + mentions: list[MentionResponse] + unread_count: int From d24759f691ced753e553c08756c17d9d66902bb9 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 17:09:59 +0200 Subject: [PATCH 08/57] Add comments service with get_comments_for_message method --- surfsense_backend/app/schemas/comments.py | 4 - .../app/services/comments_service.py | 146 ++++++++++++++++++ 2 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 surfsense_backend/app/services/comments_service.py diff --git a/surfsense_backend/app/schemas/comments.py b/surfsense_backend/app/schemas/comments.py index 1cc7de0a2..67509cb90 100644 --- a/surfsense_backend/app/schemas/comments.py +++ b/surfsense_backend/app/schemas/comments.py @@ -55,8 +55,6 @@ class CommentReplyResponse(BaseModel): created_at: datetime updated_at: datetime is_edited: bool - can_edit: bool - can_delete: bool model_config = ConfigDict(from_attributes=True) @@ -72,8 +70,6 @@ class CommentResponse(BaseModel): created_at: datetime updated_at: datetime is_edited: bool - can_edit: bool - can_delete: bool reply_count: int replies: list[CommentReplyResponse] = [] diff --git a/surfsense_backend/app/services/comments_service.py b/surfsense_backend/app/services/comments_service.py new file mode 100644 index 000000000..beedc5011 --- /dev/null +++ b/surfsense_backend/app/services/comments_service.py @@ -0,0 +1,146 @@ +""" +Service layer for chat comments and mentions. +""" + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db import ( + ChatComment, + NewChatMessage, + Permission, + User, + has_permission, +) +from app.schemas.comments import ( + AuthorResponse, + CommentListResponse, + CommentReplyResponse, + CommentResponse, +) +from app.utils.rbac import check_permission, get_user_permissions + + +async def get_comments_for_message( + session: AsyncSession, + message_id: int, + user: User, +) -> CommentListResponse: + """ + Get all comments for a message with their replies. + + Args: + session: Database session + message_id: ID of the message to get comments for + user: The current authenticated user + + Returns: + CommentListResponse with all top-level comments and their replies + + Raises: + HTTPException: If message not found or user lacks permission + """ + # Get the message with its thread to find search_space_id + result = await session.execute( + select(NewChatMessage) + .options(selectinload(NewChatMessage.thread)) + .filter(NewChatMessage.id == message_id) + ) + message = result.scalars().first() + + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + search_space_id = message.thread.search_space_id + + # Check permission to read comments + await check_permission( + session, + user, + search_space_id, + Permission.COMMENTS_READ.value, + "You don't have permission to read comments in this search space", + ) + + # Get user permissions for can_delete computation + user_permissions = await get_user_permissions(session, user.id, search_space_id) + can_delete_any = has_permission(user_permissions, Permission.COMMENTS_DELETE.value) + + # Get top-level comments (parent_id IS NULL) with their authors and replies + result = await session.execute( + select(ChatComment) + .options( + selectinload(ChatComment.author), + selectinload(ChatComment.replies).selectinload(ChatComment.author), + ) + .filter( + ChatComment.message_id == message_id, + ChatComment.parent_id.is_(None), + ) + .order_by(ChatComment.created_at) + ) + top_level_comments = result.scalars().all() + + comments = [] + for comment in top_level_comments: + # Build author response + author = None + if comment.author: + author = AuthorResponse( + id=comment.author.id, + display_name=comment.author.display_name, + avatar_url=comment.author.avatar_url, + email=comment.author.email, + ) + + # Build replies + replies = [] + for reply in sorted(comment.replies, key=lambda r: r.created_at): + reply_author = None + if reply.author: + reply_author = AuthorResponse( + id=reply.author.id, + display_name=reply.author.display_name, + avatar_url=reply.author.avatar_url, + email=reply.author.email, + ) + + is_reply_author = reply.author_id == user.id if reply.author_id else False + replies.append( + CommentReplyResponse( + id=reply.id, + content=reply.content, + content_rendered=reply.content, # TODO: render mentions in Phase 3 + author=reply_author, + created_at=reply.created_at, + updated_at=reply.updated_at, + is_edited=reply.updated_at > reply.created_at, + can_edit=is_reply_author, + can_delete=is_reply_author or can_delete_any, + ) + ) + + is_comment_author = comment.author_id == user.id if comment.author_id else False + comments.append( + CommentResponse( + id=comment.id, + message_id=comment.message_id, + content=comment.content, + content_rendered=comment.content, # TODO: render mentions in Phase 3 + author=author, + created_at=comment.created_at, + updated_at=comment.updated_at, + is_edited=comment.updated_at > comment.created_at, + can_edit=is_comment_author, + can_delete=is_comment_author or can_delete_any, + reply_count=len(replies), + replies=replies, + ) + ) + + return CommentListResponse( + comments=comments, + total_count=len(comments), + ) From 5d9294b701d279b1a6622f06ba1dff951d4693fe Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 17:27:42 +0200 Subject: [PATCH 09/57] Add create_comment method to comments service --- .../app/services/comments_service.py | 86 ++++++++++++++++++- 1 file changed, 82 insertions(+), 4 deletions(-) diff --git a/surfsense_backend/app/services/comments_service.py b/surfsense_backend/app/services/comments_service.py index beedc5011..9acf7fbd0 100644 --- a/surfsense_backend/app/services/comments_service.py +++ b/surfsense_backend/app/services/comments_service.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import selectinload from app.db import ( ChatComment, NewChatMessage, + NewChatMessageRole, Permission, User, has_permission, @@ -40,9 +41,8 @@ async def get_comments_for_message( CommentListResponse with all top-level comments and their replies Raises: - HTTPException: If message not found or user lacks permission + HTTPException: If message not found or user lacks COMMENTS_READ permission """ - # Get the message with its thread to find search_space_id result = await session.execute( select(NewChatMessage) .options(selectinload(NewChatMessage.thread)) @@ -85,7 +85,6 @@ async def get_comments_for_message( comments = [] for comment in top_level_comments: - # Build author response author = None if comment.author: author = AuthorResponse( @@ -95,7 +94,6 @@ async def get_comments_for_message( email=comment.author.email, ) - # Build replies replies = [] for reply in sorted(comment.replies, key=lambda r: r.created_at): reply_author = None @@ -144,3 +142,83 @@ async def get_comments_for_message( comments=comments, total_count=len(comments), ) + + +async def create_comment( + session: AsyncSession, + message_id: int, + content: str, + user: User, +) -> CommentResponse: + """ + Create a top-level comment on an AI response. + + Args: + session: Database session + message_id: ID of the message to comment on + content: Comment text content + user: The current authenticated user + + Returns: + CommentResponse for the created comment + + Raises: + HTTPException: If message not found, not AI response, or user lacks COMMENTS_CREATE permission + """ + result = await session.execute( + select(NewChatMessage) + .options(selectinload(NewChatMessage.thread)) + .filter(NewChatMessage.id == message_id) + ) + message = result.scalars().first() + + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + # Validate message is an AI response + if message.role != NewChatMessageRole.ASSISTANT: + raise HTTPException( + status_code=400, + detail="Comments can only be added to AI responses", + ) + + search_space_id = message.thread.search_space_id + + # Check permission to create comments + user_permissions = await get_user_permissions(session, user.id, search_space_id) + if not has_permission(user_permissions, Permission.COMMENTS_CREATE.value): + raise HTTPException( + status_code=403, + detail="You don't have permission to create comments in this search space", + ) + + comment = ChatComment( + message_id=message_id, + author_id=user.id, + content=content, + ) + session.add(comment) + await session.commit() + await session.refresh(comment) + + author = AuthorResponse( + id=user.id, + display_name=user.display_name, + avatar_url=user.avatar_url, + email=user.email, + ) + + return CommentResponse( + id=comment.id, + message_id=comment.message_id, + content=comment.content, + content_rendered=comment.content, # TODO: Phase 3 + author=author, + created_at=comment.created_at, + updated_at=comment.updated_at, + is_edited=False, + can_edit=True, + can_delete=True, # Author can always delete their own comment + reply_count=0, + replies=[], + ) From c1402683cc416f85f1b1bab2ca56409cc38f2735 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 17:30:39 +0200 Subject: [PATCH 10/57] Add create_reply method to comments service --- .../app/services/comments_service.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/surfsense_backend/app/services/comments_service.py b/surfsense_backend/app/services/comments_service.py index 9acf7fbd0..37deb06c3 100644 --- a/surfsense_backend/app/services/comments_service.py +++ b/surfsense_backend/app/services/comments_service.py @@ -222,3 +222,82 @@ async def create_comment( reply_count=0, replies=[], ) + + +async def create_reply( + session: AsyncSession, + comment_id: int, + content: str, + user: User, +) -> CommentReplyResponse: + """ + Create a reply to an existing comment. + + Args: + session: Database session + comment_id: ID of the parent comment to reply to + content: Reply text content + user: The current authenticated user + + Returns: + CommentReplyResponse for the created reply + + Raises: + HTTPException: If comment not found, is already a reply, or user lacks COMMENTS_CREATE permission + """ + # Get parent comment with its message and thread + result = await session.execute( + select(ChatComment) + .options(selectinload(ChatComment.message).selectinload(NewChatMessage.thread)) + .filter(ChatComment.id == comment_id) + ) + parent_comment = result.scalars().first() + + if not parent_comment: + raise HTTPException(status_code=404, detail="Comment not found") + + # Validate parent is a top-level comment (cannot reply to a reply) + if parent_comment.parent_id is not None: + raise HTTPException( + status_code=400, + detail="Cannot reply to a reply", + ) + + search_space_id = parent_comment.message.thread.search_space_id + + # Check permission to create comments + user_permissions = await get_user_permissions(session, user.id, search_space_id) + if not has_permission(user_permissions, Permission.COMMENTS_CREATE.value): + raise HTTPException( + status_code=403, + detail="You don't have permission to create comments in this search space", + ) + + reply = ChatComment( + message_id=parent_comment.message_id, + parent_id=comment_id, + author_id=user.id, + content=content, + ) + session.add(reply) + await session.commit() + await session.refresh(reply) + + author = AuthorResponse( + id=user.id, + display_name=user.display_name, + avatar_url=user.avatar_url, + email=user.email, + ) + + return CommentReplyResponse( + id=reply.id, + content=reply.content, + content_rendered=reply.content, # TODO: Phase 3 + author=author, + created_at=reply.created_at, + updated_at=reply.updated_at, + is_edited=False, + can_edit=True, + can_delete=True, # Author can always delete their own reply + ) From 9c965d524aa667cf0736fd7484eb147adc2e9d79 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 17:32:37 +0200 Subject: [PATCH 11/57] Add update_comment method to comments service --- .../app/services/comments_service.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/surfsense_backend/app/services/comments_service.py b/surfsense_backend/app/services/comments_service.py index 37deb06c3..1c244dfc3 100644 --- a/surfsense_backend/app/services/comments_service.py +++ b/surfsense_backend/app/services/comments_service.py @@ -301,3 +301,65 @@ async def create_reply( can_edit=True, can_delete=True, # Author can always delete their own reply ) + + +async def update_comment( + session: AsyncSession, + comment_id: int, + content: str, + user: User, +) -> CommentReplyResponse: + """ + Update a comment's content (author only). + + Args: + session: Database session + comment_id: ID of the comment to update + content: New comment text content + user: The current authenticated user + + Returns: + CommentReplyResponse for the updated comment + + Raises: + HTTPException: If comment not found or user is not the author + """ + result = await session.execute( + select(ChatComment) + .options(selectinload(ChatComment.author)) + .filter(ChatComment.id == comment_id) + ) + comment = result.scalars().first() + + if not comment: + raise HTTPException(status_code=404, detail="Comment not found") + + # Only author can edit their own comment + if comment.author_id != user.id: + raise HTTPException( + status_code=403, + detail="You can only edit your own comments", + ) + + comment.content = content + await session.commit() + await session.refresh(comment) + + author = AuthorResponse( + id=user.id, + display_name=user.display_name, + avatar_url=user.avatar_url, + email=user.email, + ) + + return CommentReplyResponse( + id=comment.id, + content=comment.content, + content_rendered=comment.content, # TODO: Phase 3 + author=author, + created_at=comment.created_at, + updated_at=comment.updated_at, + is_edited=comment.updated_at > comment.created_at, + can_edit=True, + can_delete=True, + ) From 41e0462ecc92b27e32850585398fb5876f04eb2f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 17:35:32 +0200 Subject: [PATCH 12/57] Add delete_comment method to comments service --- .../app/services/comments_service.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/surfsense_backend/app/services/comments_service.py b/surfsense_backend/app/services/comments_service.py index 1c244dfc3..097c36e2d 100644 --- a/surfsense_backend/app/services/comments_service.py +++ b/surfsense_backend/app/services/comments_service.py @@ -363,3 +363,51 @@ async def update_comment( can_edit=True, can_delete=True, ) + + +async def delete_comment( + session: AsyncSession, + comment_id: int, + user: User, +) -> dict: + """ + Delete a comment (author or user with COMMENTS_DELETE permission). + + Args: + session: Database session + comment_id: ID of the comment to delete + user: The current authenticated user + + Returns: + Dict with deletion confirmation + + Raises: + HTTPException: If comment not found or user lacks permission to delete + """ + result = await session.execute( + select(ChatComment) + .options(selectinload(ChatComment.message).selectinload(NewChatMessage.thread)) + .filter(ChatComment.id == comment_id) + ) + comment = result.scalars().first() + + if not comment: + raise HTTPException(status_code=404, detail="Comment not found") + + is_author = comment.author_id == user.id + + # Check if user has COMMENTS_DELETE permission + search_space_id = comment.message.thread.search_space_id + user_permissions = await get_user_permissions(session, user.id, search_space_id) + can_delete_any = has_permission(user_permissions, Permission.COMMENTS_DELETE.value) + + if not is_author and not can_delete_any: + raise HTTPException( + status_code=403, + detail="You do not have permission to delete this comment", + ) + + await session.delete(comment) + await session.commit() + + return {"message": "Comment deleted successfully", "comment_id": comment_id} From 7d43f1fb8fb1944b0f9e75648606e79d5a1e31fa Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 17:39:30 +0200 Subject: [PATCH 13/57] Add comments routes and register in app --- surfsense_backend/app/routes/__init__.py | 2 + .../app/routes/comments_routes.py | 78 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 surfsense_backend/app/routes/comments_routes.py diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 4b6df350a..313367dce 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -5,6 +5,7 @@ from .airtable_add_connector_route import ( ) from .circleback_webhook_route import router as circleback_webhook_router from .clickup_add_connector_route import router as clickup_add_connector_router +from .comments_routes import router as comments_router from .confluence_add_connector_route import router as confluence_add_connector_router from .discord_add_connector_route import router as discord_add_connector_router from .documents_routes import router as documents_router @@ -42,6 +43,7 @@ router.include_router(editor_router) router.include_router(documents_router) router.include_router(notes_router) router.include_router(new_chat_router) # Chat with assistant-ui persistence +router.include_router(comments_router) router.include_router(podcasts_router) # Podcast task status and audio router.include_router(search_source_connectors_router) router.include_router(google_calendar_add_connector_router) diff --git a/surfsense_backend/app/routes/comments_routes.py b/surfsense_backend/app/routes/comments_routes.py new file mode 100644 index 000000000..df7ad2907 --- /dev/null +++ b/surfsense_backend/app/routes/comments_routes.py @@ -0,0 +1,78 @@ +""" +Routes for chat comments and mentions. +""" + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import User, get_async_session +from app.schemas.comments import ( + CommentCreateRequest, + CommentListResponse, + CommentReplyResponse, + CommentResponse, + CommentUpdateRequest, +) +from app.services.comments_service import ( + create_comment, + create_reply, + delete_comment, + get_comments_for_message, + update_comment, +) +from app.users import current_active_user + +router = APIRouter() + + +@router.get("/messages/{message_id}/comments", response_model=CommentListResponse) +async def list_comments( + message_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """List all comments for a message with their replies.""" + return await get_comments_for_message(session, message_id, user) + + +@router.post("/messages/{message_id}/comments", response_model=CommentResponse) +async def add_comment( + message_id: int, + request: CommentCreateRequest, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """Create a top-level comment on an AI response.""" + return await create_comment(session, message_id, request.content, user) + + +@router.post("/comments/{comment_id}/replies", response_model=CommentReplyResponse) +async def add_reply( + comment_id: int, + request: CommentCreateRequest, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """Reply to an existing comment.""" + return await create_reply(session, comment_id, request.content, user) + + +@router.put("/comments/{comment_id}", response_model=CommentReplyResponse) +async def edit_comment( + comment_id: int, + request: CommentUpdateRequest, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """Update a comment's content (author only).""" + return await update_comment(session, comment_id, request.content, user) + + +@router.delete("/comments/{comment_id}") +async def remove_comment( + comment_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """Delete a comment (author or user with COMMENTS_DELETE permission).""" + return await delete_comment(session, comment_id, user) From c793e2d621a0a89e4c1196a9450f75e7c62355a3 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 18:02:07 +0200 Subject: [PATCH 14/57] Rename comments files to chat_comments and add mention parser utility --- docs/comments-implementation-guide.md | 506 ++++++++++++++++++ surfsense_backend/app/routes/__init__.py | 4 +- ...ents_routes.py => chat_comments_routes.py} | 4 +- .../schemas/{comments.py => chat_comments.py} | 0 ...ts_service.py => chat_comments_service.py} | 2 +- surfsense_backend/app/utils/chat_comments.py | 62 +++ 6 files changed, 573 insertions(+), 5 deletions(-) create mode 100644 docs/comments-implementation-guide.md rename surfsense_backend/app/routes/{comments_routes.py => chat_comments_routes.py} (96%) rename surfsense_backend/app/schemas/{comments.py => chat_comments.py} (100%) rename surfsense_backend/app/services/{comments_service.py => chat_comments_service.py} (99%) create mode 100644 surfsense_backend/app/utils/chat_comments.py diff --git a/docs/comments-implementation-guide.md b/docs/comments-implementation-guide.md new file mode 100644 index 000000000..07f35547c --- /dev/null +++ b/docs/comments-implementation-guide.md @@ -0,0 +1,506 @@ +# Comments & Mentions Implementation Guide + +> Implementation guide for adding Google Docs-style comments and @mentions to shared chats in SurfSense. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Architecture Decisions](#architecture-decisions) +3. [Database Design](#database-design) +4. [API Design](#api-design) +5. [Permission Model](#permission-model) +6. [Business Rules](#business-rules) +7. [File Structure](#file-structure) +8. [Implementation Phases](#implementation-phases) +9. [Testing Checklist](#testing-checklist) +10. [Out of Scope](#out-of-scope) + +--- + +## Overview + +### Problem Statement + +When team members view a shared AI chat, they cannot discuss specific answers in place. Users currently have to screenshot or copy-paste the output into external tools (Slack/Teams) to ask questions or validate data. This context switching causes friction and fragments organizational knowledge. + +### User Story + +As a user, I want to be able to place and reply to comments on AI responses to let my team know my thoughts without leaving SurfSense. + +### Solution + +Users can place comments on AI chat responses and reply to existing comments, similar to Google Docs, but limited to a single level of nesting per AI response. + +--- + +## Architecture Decisions + +### Threading Model + +``` +AI Response (message_id: 123) +├── Comment A (parent_id: NULL) ← Top-level comment +│ ├── Reply A1 (parent_id: A) ← Reply +│ ├── Reply A2 (parent_id: A) ← Reply +│ └── Reply A3 (parent_id: A) ← Reply +├── Comment B (parent_id: NULL) ← Top-level comment +│ └── Reply B1 (parent_id: B) ← Reply +└── Comment C (parent_id: NULL) ← Top-level comment (no replies) +``` + +- **Multiple top-level comments** allowed per AI response +- **One level of nesting** (replies to comments, but no replies to replies) +- API enforces nesting limit, not DB constraint + +### Comment Anchoring + +- Comments attach to **AI responses only** (not user messages) +- Comments attach to **entire message** (not text selection) +- Validated by checking `message.role == 'assistant'` + +### @Mentions + +- **Storage format:** `@[uuid]` in raw content +- **Display format:** `@Display Name` in rendered content +- Mentions extracted and stored in separate table for notifications +- Only search space members can be mentioned + +### Read/Unread Mentions + +- Simple boolean on mention record +- Marked read when user clicks the mention notification +- No automatic read-on-view (keep it simple) + +### Cascade Behavior + +- Deleting a comment deletes all its replies (DB CASCADE) +- Deleting/regenerating a message deletes its comments (DB CASCADE) + +### Backend-First Architecture + +- All business logic in backend +- Frontend is a thin consumer +- Backend returns computed fields: `can_edit`, `can_delete`, `is_edited`, `content_rendered` + +--- + +## Database Design + +### Table: `chat_comments` + +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | `SERIAL` | PK | Primary key | +| `message_id` | `INTEGER` | FK → `new_chat_messages(id)` ON DELETE CASCADE, NOT NULL | Which AI response | +| `parent_id` | `INTEGER` | FK → `chat_comments(id)` ON DELETE CASCADE, NULLABLE | NULL = top-level, otherwise = reply | +| `author_id` | `UUID` | FK → `user(id)` ON DELETE SET NULL | Who wrote it | +| `content` | `TEXT` | NOT NULL | Plain text, may contain `@[uuid]` | +| `created_at` | `TIMESTAMPTZ` | NOT NULL, DEFAULT NOW() | Creation time | +| `updated_at` | `TIMESTAMPTZ` | NOT NULL, DEFAULT NOW() | Last edit time | + +**Indexes:** +- `idx_comments_message_id` on `message_id` +- `idx_comments_parent_id` on `parent_id` +- `idx_comments_author_id` on `author_id` +- `idx_comments_created_at` on `created_at` + +### Table: `chat_comment_mentions` + +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | `SERIAL` | PK | Primary key | +| `comment_id` | `INTEGER` | FK → `chat_comments(id)` ON DELETE CASCADE, NOT NULL | Which comment | +| `mentioned_user_id` | `UUID` | FK → `user(id)` ON DELETE CASCADE, NOT NULL | Who was mentioned | +| `read` | `BOOLEAN` | NOT NULL, DEFAULT FALSE | Has user seen it | +| `created_at` | `TIMESTAMPTZ` | NOT NULL, DEFAULT NOW() | When mentioned | + +**Constraints:** +- `UNIQUE (comment_id, mentioned_user_id)` - Can't mention same person twice + +**Indexes:** +- `idx_mentions_user_unread` on `(mentioned_user_id) WHERE read = FALSE` (partial index) +- `idx_mentions_comment_id` on `comment_id` + +### Schema Diagram + +``` +new_chat_messages (existing) + │ + │ 1:N + ▼ +┌──────────────────────────────────┐ +│ chat_comments │ +├──────────────────────────────────┤ +│ id (PK) │ +│ message_id (FK) │ +│ parent_id (FK, self-ref) │ +│ author_id (FK → user) │ +│ content │ +│ created_at │ +│ updated_at │ +└──────────────────────────────────┘ + │ + │ 1:N + ▼ +┌──────────────────────────────────┐ +│ chat_comment_mentions │ +├──────────────────────────────────┤ +│ id (PK) │ +│ comment_id (FK) │ +│ mentioned_user_id (FK → user) │ +│ read │ +│ created_at │ +└──────────────────────────────────┘ +``` + +--- + +## API Design + +### Endpoints + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| `GET` | `/api/v1/messages/{message_id}/comments` | List comments with replies | +| `POST` | `/api/v1/messages/{message_id}/comments` | Create top-level comment | +| `POST` | `/api/v1/comments/{comment_id}/replies` | Reply to a comment | +| `PUT` | `/api/v1/comments/{comment_id}` | Edit comment (author only) | +| `DELETE` | `/api/v1/comments/{comment_id}` | Delete comment + replies | +| `GET` | `/api/v1/users/me/mentions` | List user's mentions | +| `POST` | `/api/v1/mentions/{mention_id}/read` | Mark mention as read | +| `POST` | `/api/v1/users/me/mentions/read-all` | Mark all mentions read | + +### Request Schemas + +**Create/Update Comment:** +```json +{ + "content": "string (1-5000 chars)" +} +``` + +### Response Schemas + +**Comment Response:** +```json +{ + "id": 1, + "message_id": 123, + "content": "Is this accurate? @[uuid-here]", + "content_rendered": "Is this accurate? @John Doe", + "author": { + "id": "uuid", + "display_name": "Jane Smith", + "avatar_url": "https://...", + "email": "jane@example.com" + }, + "created_at": "2024-01-15T10:42:00Z", + "updated_at": "2024-01-15T10:42:00Z", + "is_edited": false, + "can_edit": true, + "can_delete": true, + "reply_count": 2, + "replies": [ + { + "id": 2, + "content": "Yes, verified", + "content_rendered": "Yes, verified", + "author": { ... }, + "created_at": "...", + "updated_at": "...", + "is_edited": false, + "can_edit": false, + "can_delete": false + } + ] +} +``` + +**Comment List Response:** +```json +{ + "comments": [ ... ], + "total_count": 5 +} +``` + +**Mention Response:** +```json +{ + "id": 1, + "read": false, + "created_at": "2024-01-15T10:42:00Z", + "comment": { + "id": 5, + "content_preview": "Hey, can you check...", + "author": { + "display_name": "John Doe", + "avatar_url": "..." + }, + "created_at": "..." + }, + "context": { + "thread_id": 123, + "thread_title": "Q3 Analysis", + "message_id": 456, + "search_space_id": 1, + "search_space_name": "Finance Team" + } +} +``` + +**Mention List Response:** +```json +{ + "mentions": [ ... ], + "unread_count": 3 +} +``` + +--- + +## Permission Model + +### New Permissions + +Add to `Permission` enum in `db.py`: + +| Permission | Value | +|------------|-------| +| `COMMENTS_CREATE` | `"comments:create"` | +| `COMMENTS_READ` | `"comments:read"` | +| `COMMENTS_DELETE` | `"comments:delete"` | + +### Default Role Assignments + +| Role | `comments:read` | `comments:create` | `comments:delete` | +|------|-----------------|-------------------|-------------------| +| Owner | ✅ | ✅ | ✅ | +| Admin | ✅ | ✅ | ✅ | +| Editor | ✅ | ✅ | ❌ | +| Viewer | ✅ | ✅ | ❌ | + +### Authorization Rules + +| Action | Who Can Do It | +|--------|---------------| +| Read comments | Anyone with `comments:read` | +| Create comment | Anyone with `comments:create` | +| Create reply | Anyone with `comments:create` | +| Edit comment | Author only | +| Delete own comment | Author | +| Delete any comment | Anyone with `comments:delete` | + +--- + +## Business Rules + +### Validation Rules + +1. **Message must be AI response:** `message.role == 'assistant'` +2. **Reply parent must be top-level:** `parent.parent_id IS NULL` +3. **Content not empty:** 1-5000 characters +4. **Mentioned users must be search space members** + +### Computed Fields (Backend Returns) + +| Field | Logic | +|-------|-------| +| `is_edited` | `updated_at > created_at` | +| `can_edit` | `comment.author_id == current_user.id` | +| `can_delete` | `author_id == user.id OR has_permission("comments:delete")` | +| `content_rendered` | Replace `@[uuid]` with `@Display Name` | +| `reply_count` | Count of replies | + +### Mention Processing + +1. On comment create/update, parse `@[uuid]` patterns from content +2. Validate each UUID is a member of the search space +3. Insert/update records in `chat_comment_mentions` +4. Invalid UUIDs: silently ignore (don't create mention record) + +### Error Responses + +| Scenario | HTTP Status | Message | +|----------|-------------|---------| +| Message not found | 404 | "Message not found" | +| Message is not AI response | 400 | "Comments can only be added to AI responses" | +| Comment not found | 404 | "Comment not found" | +| Cannot reply to reply | 400 | "Cannot reply to a reply" | +| Not authorized to edit | 403 | "You can only edit your own comments" | +| Not authorized to delete | 403 | "You do not have permission to delete this comment" | +| Mention not found | 404 | "Mention not found" | + +--- + +## File Structure + +``` +surfsense_backend/app/ +├── routes/ +│ └── comments_routes.py # All comment endpoints +│ +├── services/ +│ └── comments_service.py # Business logic + DB access +│ +├── schemas/ +│ └── comments.py # Request/response schemas +│ +├── utils/ +│ └── comments.py # Mention parsing, helpers +│ +├── db.py # Add models (ChatComment, ChatCommentMention) +│ +└── alembic/versions/ + └── XX_add_comments_tables.py # Migration +``` + +## Implementation Phases + +### Phase 1: Foundation (P0) + +| Task | Description | +|------|-------------| +| 1.1 | Create `chat_comments` table migration | +| 1.2 | Create `chat_comment_mentions` table migration | +| 1.3 | Add `ChatComment` model to `db.py` | +| 1.4 | Add `ChatCommentMention` model to `db.py` | +| 1.5 | Add comment permissions to `Permission` enum | +| 1.6 | Update `DEFAULT_ROLE_PERMISSIONS` | + +### Phase 2: Core CRUD (P0) + +| Task | Description | +|------|-------------| +| 2.1 | Create Pydantic schemas in `schemas/comments.py` | +| 2.2 | Implement `GET /messages/{id}/comments` | +| 2.3 | Implement `POST /messages/{id}/comments` | +| 2.4 | Implement `POST /comments/{id}/replies` | +| 2.5 | Implement `PUT /comments/{id}` | +| 2.6 | Implement `DELETE /comments/{id}` | + +### Phase 3: Mentions (P1) + +| Task | Description | +|------|-------------| +| 3.1 | Create mention parser in `utils/comments.py` | +| 3.2 | Validate mentioned users are search space members | +| 3.3 | Insert mentions on comment create/update | +| 3.4 | Return `content_rendered` with names resolved | +| 3.5 | Implement `GET /users/me/mentions` | +| 3.6 | Implement `POST /mentions/{id}/read` | +| 3.7 | Implement `POST /users/me/mentions/read-all` | + +### Phase 4: Authorization (P1) + +| Task | Description | +|------|-------------| +| 4.1 | Check `comments:read` on list endpoint | +| 4.2 | Check `comments:create` on create/reply | +| 4.3 | Check author-only on edit | +| 4.4 | Check author OR `comments:delete` on delete | +| 4.5 | Validate message is AI response | +| 4.6 | Validate parent is top-level | + +### Phase 5: Response Enrichment (P1) + +| Task | Description | +|------|-------------| +| 5.1 | Return `can_edit` per comment | +| 5.2 | Return `can_delete` per comment | +| 5.3 | Return `is_edited` | +| 5.4 | Return author info (name, avatar, email fallback) | +| 5.5 | Return `reply_count` per top-level comment | + +### Phase 6: Edge Cases (P2) + +| Task | Description | +|------|-------------| +| 6.1 | Handle deleted user (author_id SET NULL) | +| 6.2 | Handle mention of user no longer in search space | +| 6.3 | Clear error when replying to deleted comment | + +--- + +## Testing Checklist (will be done manual) + +### Functional Tests + +- [ ] Create comment on AI response +- [ ] Create comment on user message (should fail) +- [ ] Reply to comment +- [ ] Reply to reply (should fail) +- [ ] Edit own comment +- [ ] Edit other's comment (should fail) +- [ ] Delete own comment (and verify replies deleted) +- [ ] Delete other's comment as owner/admin +- [ ] Delete other's comment as editor/viewer (should fail) + +### Mention Tests + +- [ ] Mention valid user creates mention record +- [ ] Mention invalid UUID is ignored +- [ ] Mention non-member is ignored +- [ ] `content_rendered` shows display names +- [ ] List mentions returns correct data +- [ ] Mark mention read updates `read` flag +- [ ] Mark all read updates all mentions + +### Edge Case Tests + +- [ ] Comment with 10+ replies scrolls properly (frontend) +- [ ] Delete parent comment cascades to replies +- [ ] Regenerate AI response deletes comments +- [ ] Comment on one-word AI response (frontend min height) +- [ ] User A deletes comment while User B replies → clear error + +### Permission Tests + +- [ ] Viewer can read comments +- [ ] Viewer can create comments +- [ ] Viewer cannot delete other's comments +- [ ] Owner can delete any comment + +--- + +## Out of Scope + +The following are explicitly NOT included in v1: + +| Feature | Reason | +|---------|--------| +| Multiple threads per message | Simplicity | +| Text selection comments | Complexity | +| Rich text (bold/italic/images) | Complexity | +| Email/push notifications | Infrastructure | +| Emoji reactions | Scope | +| Comment on user messages | Focus on AI responses | +| Nested replies (> 2 levels) | UX complexity | + +--- + +## Future Considerations (Not Now) + +These are documented for future reference but NOT to be implemented now: + +1. **Stale comment detection:** Store `message_content_hash` to detect if AI response changed +2. **Real-time sync:** Electric-SQL integration for live updates +3. **Notification center:** Dedicated page for all notifications +4. **Email digests:** Periodic email summaries of mentions +5. **Comment reactions:** Thumbs up, etc. + +--- + +## References + +- Existing patterns: `routes/new_chat_routes.py`, `services/connector_service.py` +- Permission system: `db.py` (Permission enum, DEFAULT_ROLE_PERMISSIONS) +- Schema patterns: `schemas/new_chat.py` + +# Important rules + +- Never do actions in bulk , you will always need my approval before doing something i dod not specifically mention in the prompt +- After each item task , we need to commit , for the backend we need to make sure , there are no ruff errors (ruff check, ruff format); for the frontend we need to run pnpm format:fix at web project. +- Commits should have minimal message and use --no-verify flag \ No newline at end of file diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 313367dce..62d287890 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -3,9 +3,9 @@ from fastapi import APIRouter from .airtable_add_connector_route import ( router as airtable_add_connector_router, ) +from .chat_comments_routes import router as chat_comments_router from .circleback_webhook_route import router as circleback_webhook_router from .clickup_add_connector_route import router as clickup_add_connector_router -from .comments_routes import router as comments_router from .confluence_add_connector_route import router as confluence_add_connector_router from .discord_add_connector_route import router as discord_add_connector_router from .documents_routes import router as documents_router @@ -43,7 +43,7 @@ router.include_router(editor_router) router.include_router(documents_router) router.include_router(notes_router) router.include_router(new_chat_router) # Chat with assistant-ui persistence -router.include_router(comments_router) +router.include_router(chat_comments_router) router.include_router(podcasts_router) # Podcast task status and audio router.include_router(search_source_connectors_router) router.include_router(google_calendar_add_connector_router) diff --git a/surfsense_backend/app/routes/comments_routes.py b/surfsense_backend/app/routes/chat_comments_routes.py similarity index 96% rename from surfsense_backend/app/routes/comments_routes.py rename to surfsense_backend/app/routes/chat_comments_routes.py index df7ad2907..8ea3ab68b 100644 --- a/surfsense_backend/app/routes/comments_routes.py +++ b/surfsense_backend/app/routes/chat_comments_routes.py @@ -6,14 +6,14 @@ from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from app.db import User, get_async_session -from app.schemas.comments import ( +from app.schemas.chat_comments import ( CommentCreateRequest, CommentListResponse, CommentReplyResponse, CommentResponse, CommentUpdateRequest, ) -from app.services.comments_service import ( +from app.services.chat_comments_service import ( create_comment, create_reply, delete_comment, diff --git a/surfsense_backend/app/schemas/comments.py b/surfsense_backend/app/schemas/chat_comments.py similarity index 100% rename from surfsense_backend/app/schemas/comments.py rename to surfsense_backend/app/schemas/chat_comments.py diff --git a/surfsense_backend/app/services/comments_service.py b/surfsense_backend/app/services/chat_comments_service.py similarity index 99% rename from surfsense_backend/app/services/comments_service.py rename to surfsense_backend/app/services/chat_comments_service.py index 097c36e2d..48c6a66e0 100644 --- a/surfsense_backend/app/services/comments_service.py +++ b/surfsense_backend/app/services/chat_comments_service.py @@ -15,7 +15,7 @@ from app.db import ( User, has_permission, ) -from app.schemas.comments import ( +from app.schemas.chat_comments import ( AuthorResponse, CommentListResponse, CommentReplyResponse, diff --git a/surfsense_backend/app/utils/chat_comments.py b/surfsense_backend/app/utils/chat_comments.py new file mode 100644 index 000000000..256782a5f --- /dev/null +++ b/surfsense_backend/app/utils/chat_comments.py @@ -0,0 +1,62 @@ +""" +Utility functions for chat comments, including mention parsing. +""" + +import re +from uuid import UUID + +# Pattern to match @[uuid] mentions in comment content +MENTION_PATTERN = re.compile(r"@\[([0-9a-fA-F-]{36})\]") + + +def parse_mentions(content: str) -> list[UUID]: + """ + Extract user UUIDs from @[uuid] mentions in content. + + Args: + content: Comment text that may contain @[uuid] mentions + + Returns: + List of unique user UUIDs found in the content + """ + matches = MENTION_PATTERN.findall(content) + unique_uuids = [] + seen = set() + + for match in matches: + try: + uuid = UUID(match) + if uuid not in seen: + seen.add(uuid) + unique_uuids.append(uuid) + except ValueError: + # Invalid UUID format, skip + continue + + return unique_uuids + + +def render_mentions(content: str, user_names: dict[UUID, str]) -> str: + """ + Replace @[uuid] mentions with @DisplayName in content. + + Args: + content: Comment text with @[uuid] mentions + user_names: Dict mapping user UUIDs to display names + + Returns: + Content with mentions rendered as @DisplayName + """ + + def replace_mention(match: re.Match) -> str: + try: + uuid = UUID(match.group(1)) + name = user_names.get(uuid) + if name: + return f"@{name}" + # Keep original format if user not found + return match.group(0) + except ValueError: + return match.group(0) + + return MENTION_PATTERN.sub(replace_mention, content) From c82a94cf02f386d1913e041c6012d0aa8621a47b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 18:30:21 +0200 Subject: [PATCH 15/57] Add mention processing and rendering in chat comments service --- .../app/services/chat_comments_service.py | 167 ++++++++++++++++-- 1 file changed, 157 insertions(+), 10 deletions(-) diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py index 48c6a66e0..57066f114 100644 --- a/surfsense_backend/app/services/chat_comments_service.py +++ b/surfsense_backend/app/services/chat_comments_service.py @@ -2,16 +2,20 @@ Service layer for chat comments and mentions. """ +from uuid import UUID + from fastapi import HTTPException -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.db import ( ChatComment, + ChatCommentMention, NewChatMessage, NewChatMessageRole, Permission, + SearchSpaceMembership, User, has_permission, ) @@ -21,9 +25,72 @@ from app.schemas.chat_comments import ( CommentReplyResponse, CommentResponse, ) +from app.utils.chat_comments import parse_mentions, render_mentions from app.utils.rbac import check_permission, get_user_permissions +async def get_user_names_for_mentions( + session: AsyncSession, + user_ids: set[UUID], +) -> dict[UUID, str]: + """ + Fetch display names for a set of user IDs. + + Args: + session: Database session + user_ids: Set of user UUIDs to look up + + Returns: + Dictionary mapping user UUID to display name + """ + if not user_ids: + return {} + + result = await session.execute( + select(User.id, User.display_name).filter(User.id.in_(user_ids)) + ) + return {row.id: row.display_name or "Unknown" for row in result.all()} + + +async def process_mentions( + session: AsyncSession, + comment_id: int, + content: str, + search_space_id: int, +) -> None: + """ + Parse mentions from content, validate users are members, and insert mention records. + + Args: + session: Database session + comment_id: ID of the comment containing mentions + content: Comment text with @[uuid] mentions + search_space_id: ID of the search space for membership validation + """ + mentioned_uuids = parse_mentions(content) + if not mentioned_uuids: + return + + # Get valid members from the mentioned UUIDs + result = await session.execute( + select(SearchSpaceMembership.user_id).filter( + SearchSpaceMembership.search_space_id == search_space_id, + SearchSpaceMembership.user_id.in_(mentioned_uuids), + ) + ) + valid_member_ids = result.scalars().all() + + # Insert mention records for valid members + for user_id in valid_member_ids: + mention = ChatCommentMention( + comment_id=comment_id, + mentioned_user_id=user_id, + ) + session.add(mention) + + await session.flush() + + async def get_comments_for_message( session: AsyncSession, message_id: int, @@ -83,6 +150,16 @@ async def get_comments_for_message( ) top_level_comments = result.scalars().all() + # Collect all mentioned UUIDs from comments and replies for rendering + all_mentioned_uuids: set[UUID] = set() + for comment in top_level_comments: + all_mentioned_uuids.update(parse_mentions(comment.content)) + for reply in comment.replies: + all_mentioned_uuids.update(parse_mentions(reply.content)) + + # Fetch display names for mentioned users + user_names = await get_user_names_for_mentions(session, all_mentioned_uuids) + comments = [] for comment in top_level_comments: author = None @@ -110,7 +187,7 @@ async def get_comments_for_message( CommentReplyResponse( id=reply.id, content=reply.content, - content_rendered=reply.content, # TODO: render mentions in Phase 3 + content_rendered=render_mentions(reply.content, user_names), author=reply_author, created_at=reply.created_at, updated_at=reply.updated_at, @@ -126,7 +203,7 @@ async def get_comments_for_message( id=comment.id, message_id=comment.message_id, content=comment.content, - content_rendered=comment.content, # TODO: render mentions in Phase 3 + content_rendered=render_mentions(comment.content, user_names), author=author, created_at=comment.created_at, updated_at=comment.updated_at, @@ -198,9 +275,18 @@ async def create_comment( content=content, ) session.add(comment) + await session.flush() + + # Process mentions + await process_mentions(session, comment.id, content, search_space_id) + await session.commit() await session.refresh(comment) + # Fetch user names for rendering mentions + mentioned_uuids = set(parse_mentions(content)) + user_names = await get_user_names_for_mentions(session, mentioned_uuids) + author = AuthorResponse( id=user.id, display_name=user.display_name, @@ -212,13 +298,13 @@ async def create_comment( id=comment.id, message_id=comment.message_id, content=comment.content, - content_rendered=comment.content, # TODO: Phase 3 + content_rendered=render_mentions(content, user_names), author=author, created_at=comment.created_at, updated_at=comment.updated_at, is_edited=False, can_edit=True, - can_delete=True, # Author can always delete their own comment + can_delete=True, reply_count=0, replies=[], ) @@ -280,9 +366,18 @@ async def create_reply( content=content, ) session.add(reply) + await session.flush() + + # Process mentions + await process_mentions(session, reply.id, content, search_space_id) + await session.commit() await session.refresh(reply) + # Fetch user names for rendering mentions + mentioned_uuids = set(parse_mentions(content)) + user_names = await get_user_names_for_mentions(session, mentioned_uuids) + author = AuthorResponse( id=user.id, display_name=user.display_name, @@ -293,13 +388,13 @@ async def create_reply( return CommentReplyResponse( id=reply.id, content=reply.content, - content_rendered=reply.content, # TODO: Phase 3 + content_rendered=render_mentions(content, user_names), author=author, created_at=reply.created_at, updated_at=reply.updated_at, is_edited=False, can_edit=True, - can_delete=True, # Author can always delete their own reply + can_delete=True, ) @@ -326,7 +421,10 @@ async def update_comment( """ result = await session.execute( select(ChatComment) - .options(selectinload(ChatComment.author)) + .options( + selectinload(ChatComment.author), + selectinload(ChatComment.message).selectinload(NewChatMessage.thread), + ) .filter(ChatComment.id == comment_id) ) comment = result.scalars().first() @@ -334,17 +432,66 @@ async def update_comment( if not comment: raise HTTPException(status_code=404, detail="Comment not found") - # Only author can edit their own comment if comment.author_id != user.id: raise HTTPException( status_code=403, detail="You can only edit your own comments", ) + search_space_id = comment.message.thread.search_space_id + + # Get existing mentioned user IDs + existing_result = await session.execute( + select(ChatCommentMention.mentioned_user_id).filter( + ChatCommentMention.comment_id == comment_id + ) + ) + existing_mention_ids = set(existing_result.scalars().all()) + + # Parse new mentions from updated content + new_mention_uuids = set(parse_mentions(content)) + + # Validate new mentions are search space members + if new_mention_uuids: + valid_result = await session.execute( + select(SearchSpaceMembership.user_id).filter( + SearchSpaceMembership.search_space_id == search_space_id, + SearchSpaceMembership.user_id.in_(new_mention_uuids), + ) + ) + valid_new_mentions = set(valid_result.scalars().all()) + else: + valid_new_mentions = set() + + # Compute diff: removed, kept (preserve read status), added + mentions_to_remove = existing_mention_ids - valid_new_mentions + mentions_to_add = valid_new_mentions - existing_mention_ids + + # Delete removed mentions + if mentions_to_remove: + await session.execute( + delete(ChatCommentMention).where( + ChatCommentMention.comment_id == comment_id, + ChatCommentMention.mentioned_user_id.in_(mentions_to_remove), + ) + ) + + # Add new mentions (existing ones keep their read status) + for user_id in mentions_to_add: + mention = ChatCommentMention( + comment_id=comment_id, + mentioned_user_id=user_id, + ) + session.add(mention) + comment.content = content + await session.commit() await session.refresh(comment) + # Fetch user names for rendering mentions + user_names = await get_user_names_for_mentions(session, valid_new_mentions) + author = AuthorResponse( id=user.id, display_name=user.display_name, @@ -355,7 +502,7 @@ async def update_comment( return CommentReplyResponse( id=comment.id, content=comment.content, - content_rendered=comment.content, # TODO: Phase 3 + content_rendered=render_mentions(content, user_names), author=author, created_at=comment.created_at, updated_at=comment.updated_at, From 7504411dcfbf857081a6477f7af674f5963010db Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 18:49:12 +0200 Subject: [PATCH 16/57] Add mention service methods (get mentions, mark as read) --- .../app/services/chat_comments_service.py | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py index 57066f114..6bed682b5 100644 --- a/surfsense_backend/app/services/chat_comments_service.py +++ b/surfsense_backend/app/services/chat_comments_service.py @@ -14,6 +14,7 @@ from app.db import ( ChatCommentMention, NewChatMessage, NewChatMessageRole, + NewChatThread, Permission, SearchSpaceMembership, User, @@ -24,6 +25,10 @@ from app.schemas.chat_comments import ( CommentListResponse, CommentReplyResponse, CommentResponse, + MentionCommentResponse, + MentionContextResponse, + MentionListResponse, + MentionResponse, ) from app.utils.chat_comments import parse_mentions, render_mentions from app.utils.rbac import check_permission, get_user_permissions @@ -558,3 +563,177 @@ async def delete_comment( await session.commit() return {"message": "Comment deleted successfully", "comment_id": comment_id} + + +async def get_user_mentions( + session: AsyncSession, + user: User, + search_space_id: int | None = None, + unread_only: bool = False, +) -> MentionListResponse: + """ + Get mentions for the current user, optionally filtered by search space. + + Args: + session: Database session + user: The current authenticated user + search_space_id: Optional search space ID to filter mentions + unread_only: If True, only return unread mentions + + Returns: + MentionListResponse with mentions and unread count + """ + # Build query with joins for filtering by search_space_id + query = ( + select(ChatCommentMention) + .join(ChatComment, ChatCommentMention.comment_id == ChatComment.id) + .join(NewChatMessage, ChatComment.message_id == NewChatMessage.id) + .join(NewChatThread, NewChatMessage.thread_id == NewChatThread.id) + .options( + selectinload(ChatCommentMention.comment).selectinload(ChatComment.author), + ) + .filter(ChatCommentMention.mentioned_user_id == user.id) + .order_by(ChatCommentMention.created_at.desc()) + ) + + if search_space_id is not None: + query = query.filter(NewChatThread.search_space_id == search_space_id) + + if unread_only: + query = query.filter(ChatCommentMention.read.is_(False)) + + result = await session.execute(query) + mention_records = result.scalars().all() + + # Fetch search space info for context (single query for all unique search spaces) + thread_ids = {m.comment.message.thread_id for m in mention_records} + if thread_ids: + thread_result = await session.execute( + select(NewChatThread) + .options(selectinload(NewChatThread.search_space)) + .filter(NewChatThread.id.in_(thread_ids)) + ) + threads_map = {t.id: t for t in thread_result.scalars().all()} + else: + threads_map = {} + + # Count unread from fetched data + unread_count = sum(1 for m in mention_records if not m.read) + + mentions = [] + for mention in mention_records: + comment = mention.comment + message = comment.message + thread = threads_map.get(message.thread_id) + search_space = thread.search_space if thread else None + + author = None + if comment.author: + author = AuthorResponse( + id=comment.author.id, + display_name=comment.author.display_name, + avatar_url=comment.author.avatar_url, + email=comment.author.email, + ) + + content_preview = ( + comment.content[:100] + "..." + if len(comment.content) > 100 + else comment.content + ) + + mentions.append( + MentionResponse( + id=mention.id, + read=mention.read, + created_at=mention.created_at, + comment=MentionCommentResponse( + id=comment.id, + content_preview=content_preview, + author=author, + created_at=comment.created_at, + ), + context=MentionContextResponse( + thread_id=thread.id if thread else 0, + thread_title=thread.title or "Untitled" if thread else "Unknown", + message_id=message.id, + search_space_id=search_space.id if search_space else 0, + search_space_name=search_space.name if search_space else "Unknown", + ), + ) + ) + + return MentionListResponse( + mentions=mentions, + unread_count=unread_count, + ) + + +async def mark_mention_as_read( + session: AsyncSession, + mention_id: int, + user: User, +) -> dict: + """ + Mark a specific mention as read. + + Args: + session: Database session + mention_id: ID of the mention to mark as read + user: The current authenticated user + + Returns: + Dict with mention_id and read status + + Raises: + HTTPException: If mention not found or doesn't belong to user + """ + result = await session.execute( + select(ChatCommentMention).filter(ChatCommentMention.id == mention_id) + ) + mention = result.scalars().first() + + if not mention: + raise HTTPException(status_code=404, detail="Mention not found") + + if mention.mentioned_user_id != user.id: + raise HTTPException( + status_code=403, + detail="You can only mark your own mentions as read", + ) + + mention.read = True + await session.commit() + + return {"mention_id": mention_id, "read": True} + + +async def mark_all_mentions_as_read( + session: AsyncSession, + user: User, +) -> dict: + """ + Mark all mentions for the current user as read. + + Args: + session: Database session + user: The current authenticated user + + Returns: + Dict with count of mentions marked as read + """ + from sqlalchemy import update + + result = await session.execute( + update(ChatCommentMention) + .where( + ChatCommentMention.mentioned_user_id == user.id, + ChatCommentMention.read.is_(False), + ) + .values(read=True) + .returning(ChatCommentMention.id) + ) + marked_ids = result.scalars().all() + await session.commit() + + return {"message": "All mentions marked as read", "count": len(marked_ids)} From 4792fb71e2131422bcd872157787a55600957b49 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 18:51:27 +0200 Subject: [PATCH 17/57] Add mention routes (list, mark as read, mark all as read) --- .../app/routes/chat_comments_routes.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/surfsense_backend/app/routes/chat_comments_routes.py b/surfsense_backend/app/routes/chat_comments_routes.py index 8ea3ab68b..395476b88 100644 --- a/surfsense_backend/app/routes/chat_comments_routes.py +++ b/surfsense_backend/app/routes/chat_comments_routes.py @@ -12,12 +12,16 @@ from app.schemas.chat_comments import ( CommentReplyResponse, CommentResponse, CommentUpdateRequest, + MentionListResponse, ) from app.services.chat_comments_service import ( create_comment, create_reply, delete_comment, get_comments_for_message, + get_user_mentions, + mark_all_mentions_as_read, + mark_mention_as_read, update_comment, ) from app.users import current_active_user @@ -76,3 +80,38 @@ async def remove_comment( ): """Delete a comment (author or user with COMMENTS_DELETE permission).""" return await delete_comment(session, comment_id, user) + + +# ============================================================================= +# Mention Routes +# ============================================================================= + + +@router.get("/mentions", response_model=MentionListResponse) +async def list_mentions( + search_space_id: int | None = None, + unread_only: bool = False, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """List mentions for the current user.""" + return await get_user_mentions(session, user, search_space_id, unread_only) + + +@router.put("/mentions/{mention_id}/read") +async def read_mention( + mention_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """Mark a specific mention as read.""" + return await mark_mention_as_read(session, mention_id, user) + + +@router.put("/mentions/read-all") +async def read_all_mentions( + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """Mark all mentions as read for the current user.""" + return await mark_all_mentions_as_read(session, user) From 37612f588a0c9d6cd5f6217c683293be2b96791e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 19:44:28 +0200 Subject: [PATCH 18/57] feat(web): add chat comments types --- .../contracts/types/chat-comments.types.ts | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 surfsense_web/contracts/types/chat-comments.types.ts diff --git a/surfsense_web/contracts/types/chat-comments.types.ts b/surfsense_web/contracts/types/chat-comments.types.ts new file mode 100644 index 000000000..2e10cdc9d --- /dev/null +++ b/surfsense_web/contracts/types/chat-comments.types.ts @@ -0,0 +1,167 @@ +import { z } from "zod"; + +export const author = z.object({ + id: z.string().uuid(), + display_name: z.string().nullable(), + avatar_url: z.string().nullable(), + email: z.string(), +}); + +export const commentReply = z.object({ + id: z.number(), + content: z.string(), + content_rendered: z.string(), + author: author.nullable(), + created_at: z.string(), + updated_at: z.string(), + is_edited: z.boolean(), + can_edit: z.boolean(), + can_delete: z.boolean(), +}); + +export const comment = z.object({ + id: z.number(), + message_id: z.number(), + content: z.string(), + content_rendered: z.string(), + author: author.nullable(), + created_at: z.string(), + updated_at: z.string(), + is_edited: z.boolean(), + can_edit: z.boolean(), + can_delete: z.boolean(), + reply_count: z.number(), + replies: z.array(commentReply), +}); + +export const mentionContext = z.object({ + thread_id: z.number(), + thread_title: z.string(), + message_id: z.number(), + search_space_id: z.number(), + search_space_name: z.string(), +}); + +export const mentionComment = z.object({ + id: z.number(), + content_preview: z.string(), + author: author.nullable(), + created_at: z.string(), +}); + +export const mention = z.object({ + id: z.number(), + read: z.boolean(), + created_at: z.string(), + comment: mentionComment, + context: mentionContext, +}); + +/** + * Get comments for a message + */ +export const getCommentsRequest = z.object({ + message_id: z.number(), +}); + +export const getCommentsResponse = z.object({ + comments: z.array(comment), + total_count: z.number(), +}); + +/** + * Create comment + */ +export const createCommentRequest = z.object({ + message_id: z.number(), + content: z.string().min(1).max(5000), +}); + +export const createCommentResponse = comment; + +/** + * Create reply + */ +export const createReplyRequest = z.object({ + comment_id: z.number(), + content: z.string().min(1).max(5000), +}); + +export const createReplyResponse = commentReply; + +/** + * Update comment + */ +export const updateCommentRequest = z.object({ + comment_id: z.number(), + content: z.string().min(1).max(5000), +}); + +export const updateCommentResponse = commentReply; + +/** + * Delete comment + */ +export const deleteCommentRequest = z.object({ + comment_id: z.number(), +}); + +export const deleteCommentResponse = z.object({ + message: z.string(), + comment_id: z.number(), +}); + +/** + * Get mentions + */ +export const getMentionsRequest = z.object({ + search_space_id: z.number().optional(), + unread_only: z.boolean().optional(), +}); + +export const getMentionsResponse = z.object({ + mentions: z.array(mention), + unread_count: z.number(), +}); + +/** + * Mark mention as read + */ +export const markMentionReadRequest = z.object({ + mention_id: z.number(), +}); + +export const markMentionReadResponse = z.object({ + mention_id: z.number(), + read: z.boolean(), +}); + +/** + * Mark all mentions as read + */ +export const markAllMentionsReadResponse = z.object({ + message: z.string(), + count: z.number(), +}); + +export type Author = z.infer; +export type CommentReply = z.infer; +export type Comment = z.infer; +export type MentionContext = z.infer; +export type MentionComment = z.infer; +export type Mention = z.infer; +export type GetCommentsRequest = z.infer; +export type GetCommentsResponse = z.infer; +export type CreateCommentRequest = z.infer; +export type CreateCommentResponse = z.infer; +export type CreateReplyRequest = z.infer; +export type CreateReplyResponse = z.infer; +export type UpdateCommentRequest = z.infer; +export type UpdateCommentResponse = z.infer; +export type DeleteCommentRequest = z.infer; +export type DeleteCommentResponse = z.infer; +export type GetMentionsRequest = z.infer; +export type GetMentionsResponse = z.infer; +export type MarkMentionReadRequest = z.infer; +export type MarkMentionReadResponse = z.infer; +export type MarkAllMentionsReadResponse = z.infer; From 5c8621429d1b3f1697782829bd3738222c4e8644 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 19:47:10 +0200 Subject: [PATCH 19/57] feat(web): add chat comments API service --- .../lib/apis/chat-comments-api.service.ts | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 surfsense_web/lib/apis/chat-comments-api.service.ts diff --git a/surfsense_web/lib/apis/chat-comments-api.service.ts b/surfsense_web/lib/apis/chat-comments-api.service.ts new file mode 100644 index 000000000..8fa683e1c --- /dev/null +++ b/surfsense_web/lib/apis/chat-comments-api.service.ts @@ -0,0 +1,168 @@ +import { + type CreateCommentRequest, + type CreateReplyRequest, + type DeleteCommentRequest, + type GetCommentsRequest, + type GetMentionsRequest, + type MarkMentionReadRequest, + type UpdateCommentRequest, + createCommentRequest, + createCommentResponse, + createReplyRequest, + createReplyResponse, + deleteCommentRequest, + deleteCommentResponse, + getCommentsRequest, + getCommentsResponse, + getMentionsRequest, + getMentionsResponse, + markAllMentionsReadResponse, + markMentionReadRequest, + markMentionReadResponse, + updateCommentRequest, + updateCommentResponse, +} from "@/contracts/types/chat-comments.types"; +import { ValidationError } from "@/lib/error"; +import { baseApiService } from "./base-api.service"; + +class ChatCommentsApiService { + /** + * Get comments for a message + */ + getComments = async (request: GetCommentsRequest) => { + const parsed = getCommentsRequest.safeParse(request); + + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.get( + `/api/v1/messages/${parsed.data.message_id}/comments`, + getCommentsResponse + ); + }; + + /** + * Create a top-level comment + */ + createComment = async (request: CreateCommentRequest) => { + const parsed = createCommentRequest.safeParse(request); + + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.post( + `/api/v1/messages/${parsed.data.message_id}/comments`, + createCommentResponse, + { body: { content: parsed.data.content } } + ); + }; + + /** + * Create a reply to a comment + */ + createReply = async (request: CreateReplyRequest) => { + const parsed = createReplyRequest.safeParse(request); + + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.post( + `/api/v1/comments/${parsed.data.comment_id}/replies`, + createReplyResponse, + { body: { content: parsed.data.content } } + ); + }; + + /** + * Update a comment + */ + updateComment = async (request: UpdateCommentRequest) => { + const parsed = updateCommentRequest.safeParse(request); + + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.put( + `/api/v1/comments/${parsed.data.comment_id}`, + updateCommentResponse, + { body: { content: parsed.data.content } } + ); + }; + + /** + * Delete a comment + */ + deleteComment = async (request: DeleteCommentRequest) => { + const parsed = deleteCommentRequest.safeParse(request); + + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.delete( + `/api/v1/comments/${parsed.data.comment_id}`, + deleteCommentResponse + ); + }; + + /** + * Get mentions for current user + */ + getMentions = async (request?: GetMentionsRequest) => { + const parsed = getMentionsRequest.safeParse(request ?? {}); + + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const params = new URLSearchParams(); + if (parsed.data.search_space_id !== undefined) { + params.set("search_space_id", String(parsed.data.search_space_id)); + } + if (parsed.data.unread_only !== undefined) { + params.set("unread_only", String(parsed.data.unread_only)); + } + + const queryString = params.toString(); + const url = queryString ? `/api/v1/mentions?${queryString}` : "/api/v1/mentions"; + + return baseApiService.get(url, getMentionsResponse); + }; + + /** + * Mark a mention as read + */ + markMentionRead = async (request: MarkMentionReadRequest) => { + const parsed = markMentionReadRequest.safeParse(request); + + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.put( + `/api/v1/mentions/${parsed.data.mention_id}/read`, + markMentionReadResponse + ); + }; + + /** + * Mark all mentions as read + */ + markAllMentionsRead = async () => { + return baseApiService.put("/api/v1/mentions/read-all", markAllMentionsReadResponse); + }; +} + +export const chatCommentsApiService = new ChatCommentsApiService(); + From 33670aceb5dc9850e025e78886879384c9beed22 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 19:51:28 +0200 Subject: [PATCH 20/57] feat(web): add comments and mentions cache keys --- surfsense_web/lib/query-client/cache-keys.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 54f411ad1..8c1316aa4 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -72,4 +72,11 @@ export const cacheKeys = { ["connectors", "google-drive", connectorId, "folders", parentId] as const, }, }, + comments: { + byMessage: (messageId: number) => ["comments", "message", messageId] as const, + }, + mentions: { + all: (searchSpaceId?: number) => ["mentions", searchSpaceId] as const, + unreadOnly: (searchSpaceId?: number) => ["mentions", "unread", searchSpaceId] as const, + }, }; From 134f9577b9d2355f8852cfee43be1f392cd29242 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 19:57:25 +0200 Subject: [PATCH 21/57] feat(web): add chat comments atoms and hooks --- .../chat-comments/comments-mutation.atoms.ts | 110 ++++++++++++++++++ .../chat-comments/comments-query.atoms.ts | 34 ++++++ surfsense_web/hooks/use-comments.ts | 19 +++ 3 files changed, 163 insertions(+) create mode 100644 surfsense_web/atoms/chat-comments/comments-mutation.atoms.ts create mode 100644 surfsense_web/atoms/chat-comments/comments-query.atoms.ts create mode 100644 surfsense_web/hooks/use-comments.ts diff --git a/surfsense_web/atoms/chat-comments/comments-mutation.atoms.ts b/surfsense_web/atoms/chat-comments/comments-mutation.atoms.ts new file mode 100644 index 000000000..a0cabe1a9 --- /dev/null +++ b/surfsense_web/atoms/chat-comments/comments-mutation.atoms.ts @@ -0,0 +1,110 @@ +import { atomWithMutation } from "jotai-tanstack-query"; +import { toast } from "sonner"; +import type { + CreateCommentRequest, + CreateReplyRequest, + DeleteCommentRequest, + MarkMentionReadRequest, + UpdateCommentRequest, +} from "@/contracts/types/chat-comments.types"; +import { chatCommentsApiService } from "@/lib/apis/chat-comments-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { queryClient } from "@/lib/query-client/client"; + +export const createCommentMutationAtom = atomWithMutation(() => ({ + mutationFn: async (request: CreateCommentRequest) => { + return chatCommentsApiService.createComment(request); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: cacheKeys.comments.byMessage(variables.message_id), + }); + }, + onError: (error: Error) => { + console.error("Error creating comment:", error); + toast.error("Failed to create comment"); + }, +})); + +export const createReplyMutationAtom = atomWithMutation(() => ({ + mutationFn: async (request: CreateReplyRequest & { message_id: number }) => { + return chatCommentsApiService.createReply(request); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: cacheKeys.comments.byMessage(variables.message_id), + }); + }, + onError: (error: Error) => { + console.error("Error creating reply:", error); + toast.error("Failed to create reply"); + }, +})); + +export const updateCommentMutationAtom = atomWithMutation(() => ({ + mutationFn: async (request: UpdateCommentRequest & { message_id: number }) => { + return chatCommentsApiService.updateComment(request); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: cacheKeys.comments.byMessage(variables.message_id), + }); + }, + onError: (error: Error) => { + console.error("Error updating comment:", error); + toast.error("Failed to update comment"); + }, +})); + +export const deleteCommentMutationAtom = atomWithMutation(() => ({ + mutationFn: async (request: DeleteCommentRequest & { message_id: number }) => { + return chatCommentsApiService.deleteComment(request); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: cacheKeys.comments.byMessage(variables.message_id), + }); + toast.success("Comment deleted"); + }, + onError: (error: Error) => { + console.error("Error deleting comment:", error); + toast.error("Failed to delete comment"); + }, +})); + +export const markMentionReadMutationAtom = atomWithMutation(() => ({ + mutationFn: async (request: MarkMentionReadRequest & { search_space_id?: number }) => { + return chatCommentsApiService.markMentionRead(request); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: cacheKeys.mentions.all(variables.search_space_id), + }); + queryClient.invalidateQueries({ + queryKey: cacheKeys.mentions.unreadOnly(variables.search_space_id), + }); + }, + onError: (error: Error) => { + console.error("Error marking mention as read:", error); + }, +})); + +export const markAllMentionsReadMutationAtom = atomWithMutation(() => ({ + mutationFn: async (request: { search_space_id?: number }) => { + return chatCommentsApiService.markAllMentionsRead(); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: cacheKeys.mentions.all(variables.search_space_id), + }); + queryClient.invalidateQueries({ + queryKey: cacheKeys.mentions.unreadOnly(variables.search_space_id), + }); + toast.success("All mentions marked as read"); + }, + onError: (error: Error) => { + console.error("Error marking all mentions as read:", error); + toast.error("Failed to mark mentions as read"); + }, +})); + diff --git a/surfsense_web/atoms/chat-comments/comments-query.atoms.ts b/surfsense_web/atoms/chat-comments/comments-query.atoms.ts new file mode 100644 index 000000000..07cf15b78 --- /dev/null +++ b/surfsense_web/atoms/chat-comments/comments-query.atoms.ts @@ -0,0 +1,34 @@ +import { atomWithQuery } from "jotai-tanstack-query"; +import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; +import { chatCommentsApiService } from "@/lib/apis/chat-comments-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +export const mentionsAtom = atomWithQuery((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + + return { + queryKey: cacheKeys.mentions.all(searchSpaceId ? Number(searchSpaceId) : undefined), + staleTime: 60 * 1000, // 1 minute + queryFn: async () => { + return chatCommentsApiService.getMentions({ + search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined, + }); + }, + }; +}); + +export const unreadMentionsAtom = atomWithQuery((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + + return { + queryKey: cacheKeys.mentions.unreadOnly(searchSpaceId ? Number(searchSpaceId) : undefined), + staleTime: 30 * 1000, // 30 seconds + queryFn: async () => { + return chatCommentsApiService.getMentions({ + search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined, + unread_only: true, + }); + }, + }; +}); + diff --git a/surfsense_web/hooks/use-comments.ts b/surfsense_web/hooks/use-comments.ts new file mode 100644 index 000000000..046ca4520 --- /dev/null +++ b/surfsense_web/hooks/use-comments.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; +import { chatCommentsApiService } from "@/lib/apis/chat-comments-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +interface UseCommentsOptions { + messageId: number; + enabled?: boolean; +} + +export function useComments({ messageId, enabled = true }: UseCommentsOptions) { + return useQuery({ + queryKey: cacheKeys.comments.byMessage(messageId), + queryFn: async () => { + return chatCommentsApiService.getComments({ message_id: messageId }); + }, + enabled: enabled && !!messageId, + }); +} + From a353fce37957bca1e382e5ea1220aaefc07e0320 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 19:58:02 +0200 Subject: [PATCH 22/57] refactor(web): remove hardcoded staleTime from mentions atoms --- surfsense_web/atoms/chat-comments/comments-query.atoms.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/surfsense_web/atoms/chat-comments/comments-query.atoms.ts b/surfsense_web/atoms/chat-comments/comments-query.atoms.ts index 07cf15b78..02b094147 100644 --- a/surfsense_web/atoms/chat-comments/comments-query.atoms.ts +++ b/surfsense_web/atoms/chat-comments/comments-query.atoms.ts @@ -8,7 +8,6 @@ export const mentionsAtom = atomWithQuery((get) => { return { queryKey: cacheKeys.mentions.all(searchSpaceId ? Number(searchSpaceId) : undefined), - staleTime: 60 * 1000, // 1 minute queryFn: async () => { return chatCommentsApiService.getMentions({ search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined, @@ -22,7 +21,6 @@ export const unreadMentionsAtom = atomWithQuery((get) => { return { queryKey: cacheKeys.mentions.unreadOnly(searchSpaceId ? Number(searchSpaceId) : undefined), - staleTime: 30 * 1000, // 30 seconds queryFn: async () => { return chatCommentsApiService.getMentions({ search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined, From 62a45e204e989370901cd21219b36e5af79a67ff Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 20:13:29 +0200 Subject: [PATCH 23/57] feat(web): add member mention picker component --- .../app/preview/chat-comments/page.tsx | 127 ++++++++++++++++++ .../member-mention-picker/index.ts | 4 + .../member-mention-item.tsx | 50 +++++++ .../member-mention-picker.tsx | 56 ++++++++ .../member-mention-picker/types.ts | 23 ++++ 5 files changed, 260 insertions(+) create mode 100644 surfsense_web/app/preview/chat-comments/page.tsx create mode 100644 surfsense_web/components/chat-comments/member-mention-picker/index.ts create mode 100644 surfsense_web/components/chat-comments/member-mention-picker/member-mention-item.tsx create mode 100644 surfsense_web/components/chat-comments/member-mention-picker/member-mention-picker.tsx create mode 100644 surfsense_web/components/chat-comments/member-mention-picker/types.ts diff --git a/surfsense_web/app/preview/chat-comments/page.tsx b/surfsense_web/app/preview/chat-comments/page.tsx new file mode 100644 index 000000000..eb6be16ad --- /dev/null +++ b/surfsense_web/app/preview/chat-comments/page.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useState } from "react"; +import { MemberMentionPicker } from "@/components/chat-comments/member-mention-picker"; +import type { MemberOption } from "@/components/chat-comments/member-mention-picker"; + +const fakeMembersData: MemberOption[] = [ + { + id: "550e8400-e29b-41d4-a716-446655440001", + displayName: "Alice Smith", + email: "alice@example.com", + avatarUrl: null, + }, + { + id: "550e8400-e29b-41d4-a716-446655440002", + displayName: "Bob Johnson", + email: "bob.johnson@example.com", + avatarUrl: null, + }, + { + id: "550e8400-e29b-41d4-a716-446655440003", + displayName: "Charlie Brown", + email: "charlie@example.com", + avatarUrl: null, + }, + { + id: "550e8400-e29b-41d4-a716-446655440004", + displayName: null, + email: "david.wilson@example.com", + avatarUrl: null, + }, + { + id: "550e8400-e29b-41d4-a716-446655440005", + displayName: "Emma Davis", + email: "emma@example.com", + avatarUrl: null, + }, +]; + +export default function ChatCommentsPreviewPage() { + const [highlightedIndex, setHighlightedIndex] = useState(0); + const [selectedMember, setSelectedMember] = useState(null); + + return ( +
+
+
+

Chat Comments UI Preview

+

+ Preview page for chat comments components with fake data +

+
+ +
+
+

After typing @

+

Shows all members

+
+ setSelectedMember(member)} + onHighlightChange={setHighlightedIndex} + /> +
+ {selectedMember && ( +
+ Selected: + + @[{selectedMember.id.slice(0, 8)}...] + + + {" → @"} + {selectedMember.displayName || selectedMember.email} + +
+ )} +
+ +
+

After typing @ali

+

Filtered to matching members

+
+ {}} + onHighlightChange={() => {}} + /> +
+
+ +
+

Loading State

+

While fetching members

+
+ {}} + onHighlightChange={() => {}} + /> +
+
+ +
+

No Results

+

After typing @xyz (no match)

+
+ {}} + onHighlightChange={() => {}} + /> +
+
+
+
+
+ ); +} diff --git a/surfsense_web/components/chat-comments/member-mention-picker/index.ts b/surfsense_web/components/chat-comments/member-mention-picker/index.ts new file mode 100644 index 000000000..45df9b18f --- /dev/null +++ b/surfsense_web/components/chat-comments/member-mention-picker/index.ts @@ -0,0 +1,4 @@ +export { MemberMentionPicker } from "./member-mention-picker"; +export { MemberMentionItem } from "./member-mention-item"; +export type { MemberOption, MemberMentionPickerProps, MemberMentionItemProps } from "./types"; + diff --git a/surfsense_web/components/chat-comments/member-mention-picker/member-mention-item.tsx b/surfsense_web/components/chat-comments/member-mention-picker/member-mention-item.tsx new file mode 100644 index 000000000..c2e85b892 --- /dev/null +++ b/surfsense_web/components/chat-comments/member-mention-picker/member-mention-item.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; +import type { MemberMentionItemProps } from "./types"; + +function getInitials(name: string, email: string): string { + if (name) { + return name + .split(" ") + .map((part) => part[0]) + .join("") + .toUpperCase() + .slice(0, 2); + } + return email[0].toUpperCase(); +} + +export function MemberMentionItem({ + member, + isHighlighted, + onSelect, + onMouseEnter, +}: MemberMentionItemProps) { + const displayName = member.displayName || member.email.split("@")[0]; + + return ( + + ); +} + diff --git a/surfsense_web/components/chat-comments/member-mention-picker/member-mention-picker.tsx b/surfsense_web/components/chat-comments/member-mention-picker/member-mention-picker.tsx new file mode 100644 index 000000000..7cc6073eb --- /dev/null +++ b/surfsense_web/components/chat-comments/member-mention-picker/member-mention-picker.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { MemberMentionItem } from "./member-mention-item"; +import type { MemberMentionPickerProps } from "./types"; + +export function MemberMentionPicker({ + members, + query, + highlightedIndex, + isLoading = false, + onSelect, + onHighlightChange, +}: MemberMentionPickerProps) { + const filteredMembers = query + ? members.filter( + (member) => + member.displayName?.toLowerCase().includes(query.toLowerCase()) || + member.email.toLowerCase().includes(query.toLowerCase()) + ) + : members; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (filteredMembers.length === 0) { + return ( +
+ {query ? "No members found" : "No members available"} +
+ ); + } + + return ( + +
+ {filteredMembers.map((member, index) => ( + onHighlightChange(index)} + /> + ))} +
+
+ ); +} + diff --git a/surfsense_web/components/chat-comments/member-mention-picker/types.ts b/surfsense_web/components/chat-comments/member-mention-picker/types.ts new file mode 100644 index 000000000..6a653a196 --- /dev/null +++ b/surfsense_web/components/chat-comments/member-mention-picker/types.ts @@ -0,0 +1,23 @@ +export interface MemberOption { + id: string; + displayName: string; + email: string; + avatarUrl?: string | null; +} + +export interface MemberMentionPickerProps { + members: MemberOption[]; + query: string; + highlightedIndex: number; + isLoading?: boolean; + onSelect: (member: MemberOption) => void; + onHighlightChange: (index: number) => void; +} + +export interface MemberMentionItemProps { + member: MemberOption; + isHighlighted: boolean; + onSelect: (member: MemberOption) => void; + onMouseEnter: () => void; +} + From 90a8a17c88f31e96d5e7365dc9991319cefec08a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 20:15:42 +0200 Subject: [PATCH 24/57] refactor(web): remove barrel file, use direct imports --- surfsense_web/app/preview/chat-comments/page.tsx | 4 ++-- .../components/chat-comments/member-mention-picker/index.ts | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) delete mode 100644 surfsense_web/components/chat-comments/member-mention-picker/index.ts diff --git a/surfsense_web/app/preview/chat-comments/page.tsx b/surfsense_web/app/preview/chat-comments/page.tsx index eb6be16ad..74c0f2dc1 100644 --- a/surfsense_web/app/preview/chat-comments/page.tsx +++ b/surfsense_web/app/preview/chat-comments/page.tsx @@ -1,8 +1,8 @@ "use client"; import { useState } from "react"; -import { MemberMentionPicker } from "@/components/chat-comments/member-mention-picker"; -import type { MemberOption } from "@/components/chat-comments/member-mention-picker"; +import { MemberMentionPicker } from "@/components/chat-comments/member-mention-picker/member-mention-picker"; +import type { MemberOption } from "@/components/chat-comments/member-mention-picker/types"; const fakeMembersData: MemberOption[] = [ { diff --git a/surfsense_web/components/chat-comments/member-mention-picker/index.ts b/surfsense_web/components/chat-comments/member-mention-picker/index.ts deleted file mode 100644 index 45df9b18f..000000000 --- a/surfsense_web/components/chat-comments/member-mention-picker/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { MemberMentionPicker } from "./member-mention-picker"; -export { MemberMentionItem } from "./member-mention-item"; -export type { MemberOption, MemberMentionPickerProps, MemberMentionItemProps } from "./types"; - From 8bfcfdd08498d82d6131bb4c08418339cb31cbef Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 15 Jan 2026 20:35:21 +0200 Subject: [PATCH 25/57] feat(web): add comment composer with robust mention detection --- .../app/preview/chat-comments/page.tsx | 158 ++++++---- .../comment-composer/comment-composer.tsx | 278 ++++++++++++++++++ .../chat-comments/comment-composer/types.ts | 24 ++ 3 files changed, 396 insertions(+), 64 deletions(-) create mode 100644 surfsense_web/components/chat-comments/comment-composer/comment-composer.tsx create mode 100644 surfsense_web/components/chat-comments/comment-composer/types.ts diff --git a/surfsense_web/app/preview/chat-comments/page.tsx b/surfsense_web/app/preview/chat-comments/page.tsx index 74c0f2dc1..0c4194c61 100644 --- a/surfsense_web/app/preview/chat-comments/page.tsx +++ b/surfsense_web/app/preview/chat-comments/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { CommentComposer } from "@/components/chat-comments/comment-composer/comment-composer"; import { MemberMentionPicker } from "@/components/chat-comments/member-mention-picker/member-mention-picker"; import type { MemberOption } from "@/components/chat-comments/member-mention-picker/types"; @@ -40,10 +41,11 @@ const fakeMembersData: MemberOption[] = [ export default function ChatCommentsPreviewPage() { const [highlightedIndex, setHighlightedIndex] = useState(0); const [selectedMember, setSelectedMember] = useState(null); + const [submittedContent, setSubmittedContent] = useState(null); return (
-
+

Chat Comments UI Preview

@@ -51,76 +53,104 @@ export default function ChatCommentsPreviewPage() {

-
-
-

After typing @

-

Shows all members

-
- setSelectedMember(member)} - onHighlightChange={setHighlightedIndex} - /> + {/* Comment Composer Section */} +
+

Comment Composer

+

+ Type @ to trigger mention picker. Use Tab/Shift+Tab/Arrow keys to navigate, Enter to select. +

+ +
+ setSubmittedContent(content)} + onCancel={() => setSubmittedContent(null)} + autoFocus + /> +
+ + {submittedContent && ( +
+ Submitted content: + + {submittedContent} +
- {selectedMember && ( -
- Selected: - - @[{selectedMember.id.slice(0, 8)}...] - - - {" → @"} - {selectedMember.displayName || selectedMember.email} - + )} +
+ + {/* Member Mention Picker Section */} +
+

Member Mention Picker (Standalone)

+ +
+
+

After typing @

+

Shows all members

+
+ setSelectedMember(member)} + onHighlightChange={setHighlightedIndex} + />
- )} -
- -
-

After typing @ali

-

Filtered to matching members

-
- {}} - onHighlightChange={() => {}} - /> + {selectedMember && ( +
+ Selected: + + @[{selectedMember.id.slice(0, 8)}...] + +
+ )}
-
-
-

Loading State

-

While fetching members

-
- {}} - onHighlightChange={() => {}} - /> +
+

After typing @ali

+

Filtered to matching members

+
+ {}} + onHighlightChange={() => {}} + /> +
-
-
-

No Results

-

After typing @xyz (no match)

-
- {}} - onHighlightChange={() => {}} - /> +
+

Loading State

+

While fetching members

+
+ {}} + onHighlightChange={() => {}} + /> +
-
-
+ +
+

No Results

+

After typing @xyz (no match)

+
+ {}} + onHighlightChange={() => {}} + /> +
+
+
+
); diff --git a/surfsense_web/components/chat-comments/comment-composer/comment-composer.tsx b/surfsense_web/components/chat-comments/comment-composer/comment-composer.tsx new file mode 100644 index 000000000..58e046117 --- /dev/null +++ b/surfsense_web/components/chat-comments/comment-composer/comment-composer.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { Send, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { MemberMentionPicker } from "../member-mention-picker/member-mention-picker"; +import type { MemberOption } from "../member-mention-picker/types"; +import type { CommentComposerProps, InsertedMention, MentionState } from "./types"; + +function convertDisplayToData( + displayContent: string, + mentions: InsertedMention[] +): string { + let result = displayContent; + + const sortedMentions = [...mentions].sort( + (a, b) => b.displayName.length - a.displayName.length + ); + + for (const mention of sortedMentions) { + const displayPattern = new RegExp(`@${escapeRegExp(mention.displayName)}(?=\\s|$|[.,!?;:])`, 'g'); + const dataFormat = `@[${mention.id}]`; + result = result.replace(displayPattern, dataFormat); + } + + return result; +} + +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function findMentionTrigger( + text: string, + cursorPos: number, + insertedMentions: InsertedMention[] +): { isActive: boolean; query: string; startIndex: number } { + const textBeforeCursor = text.slice(0, cursorPos); + + const mentionMatch = textBeforeCursor.match(/(?:^|[\s])@([^\s]*)$/); + + if (!mentionMatch) { + return { isActive: false, query: "", startIndex: 0 }; + } + + const fullMatch = mentionMatch[0]; + const query = mentionMatch[1]; + const atIndex = cursorPos - query.length - 1; + + if (atIndex > 0) { + const charBefore = text[atIndex - 1]; + if (charBefore && !/[\s]/.test(charBefore)) { + return { isActive: false, query: "", startIndex: 0 }; + } + } + + const textFromAt = text.slice(atIndex); + + for (const mention of insertedMentions) { + const mentionPattern = `@${mention.displayName}`; + + if (textFromAt.startsWith(mentionPattern)) { + const charAfterMention = text[atIndex + mentionPattern.length]; + if (!charAfterMention || /[\s.,!?;:]/.test(charAfterMention)) { + if (cursorPos <= atIndex + mentionPattern.length) { + return { isActive: false, query: "", startIndex: 0 }; + } + } + } + } + + if (query.length > 50) { + return { isActive: false, query: "", startIndex: 0 }; + } + + return { isActive: true, query, startIndex: atIndex }; +} + +export function CommentComposer({ + members, + membersLoading = false, + placeholder = "Write a comment...", + submitLabel = "Send", + isSubmitting = false, + onSubmit, + onCancel, + autoFocus = false, +}: CommentComposerProps) { + const [displayContent, setDisplayContent] = useState(""); + const [insertedMentions, setInsertedMentions] = useState([]); + const [mentionState, setMentionState] = useState({ + isActive: false, + query: "", + startIndex: 0, + }); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const textareaRef = useRef(null); + + const filteredMembers = mentionState.query + ? members.filter( + (member) => + member.displayName?.toLowerCase().includes(mentionState.query.toLowerCase()) || + member.email.toLowerCase().includes(mentionState.query.toLowerCase()) + ) + : members; + + const closeMentionPicker = useCallback(() => { + setMentionState({ isActive: false, query: "", startIndex: 0 }); + setHighlightedIndex(0); + }, []); + + const insertMention = useCallback( + (member: MemberOption) => { + const displayName = member.displayName || member.email.split("@")[0]; + const before = displayContent.slice(0, mentionState.startIndex); + const cursorPos = textareaRef.current?.selectionStart ?? displayContent.length; + const after = displayContent.slice(cursorPos); + const mentionText = `@${displayName} `; + const newContent = before + mentionText + after; + + setDisplayContent(newContent); + setInsertedMentions((prev) => { + const exists = prev.some((m) => m.id === member.id && m.displayName === displayName); + if (exists) return prev; + return [...prev, { id: member.id, displayName }]; + }); + closeMentionPicker(); + + requestAnimationFrame(() => { + if (textareaRef.current) { + const cursorPos = before.length + mentionText.length; + textareaRef.current.focus(); + textareaRef.current.setSelectionRange(cursorPos, cursorPos); + } + }); + }, + [displayContent, mentionState.startIndex, closeMentionPicker] + ); + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + const cursorPos = e.target.selectionStart; + setDisplayContent(value); + + const triggerResult = findMentionTrigger(value, cursorPos, insertedMentions); + + if (triggerResult.isActive) { + setMentionState(triggerResult); + setHighlightedIndex(0); + } else if (mentionState.isActive) { + closeMentionPicker(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!mentionState.isActive) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + return; + } + + switch (e.key) { + case "ArrowDown": + case "Tab": + if (!e.shiftKey) { + e.preventDefault(); + setHighlightedIndex((prev) => + prev < filteredMembers.length - 1 ? prev + 1 : 0 + ); + } else if (e.key === "Tab") { + e.preventDefault(); + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredMembers.length - 1 + ); + } + break; + case "ArrowUp": + e.preventDefault(); + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredMembers.length - 1 + ); + break; + case "Enter": + e.preventDefault(); + if (filteredMembers[highlightedIndex]) { + insertMention(filteredMembers[highlightedIndex]); + } + break; + case "Escape": + e.preventDefault(); + closeMentionPicker(); + break; + } + }; + + const handleSubmit = () => { + const trimmed = displayContent.trim(); + if (!trimmed || isSubmitting) return; + + const dataContent = convertDisplayToData(trimmed, insertedMentions); + onSubmit(dataContent); + setDisplayContent(""); + setInsertedMentions([]); + }; + + useEffect(() => { + if (autoFocus && textareaRef.current) { + textareaRef.current.focus(); + } + }, [autoFocus]); + + const canSubmit = displayContent.trim().length > 0 && !isSubmitting; + + return ( +
+ !open && closeMentionPicker()}> + +