Merge pull request #702 from CREDO23/sur-90-feat-comments-in-chats

[Feature] Comments & mentions in chats
This commit is contained in:
Rohan Verma 2026-01-19 13:47:58 -08:00 committed by GitHub
commit e86462a7c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 3441 additions and 78 deletions

View file

@ -37,10 +37,5 @@ def upgrade() -> None:
def downgrade() -> None:
"""Remove author_id column from new_chat_messages table."""
op.execute(
"""
DROP INDEX IF EXISTS ix_new_chat_messages_author_id;
ALTER TABLE new_chat_messages
DROP COLUMN IF EXISTS author_id;
"""
)
op.execute("DROP INDEX IF EXISTS ix_new_chat_messages_author_id")
op.execute("ALTER TABLE new_chat_messages DROP COLUMN IF EXISTS author_id")

View file

@ -0,0 +1,52 @@
"""Add chat_comments table for comments on AI responses
Revision ID: 68
Revises: 67
"""
from collections.abc import Sequence
from alembic import op
revision: str = "68"
down_revision: str | None = "67"
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()
)
"""
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_comments_message_id ON chat_comments(message_id)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_comments_parent_id ON chat_comments(parent_id)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_comments_author_id ON chat_comments(author_id)"
)
op.execute(
"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;
"""
)

View file

@ -0,0 +1,41 @@
"""Add chat_comment_mentions table for @mentions in comments
Revision ID: 69
Revises: 68
"""
from collections.abc import Sequence
from alembic import op
revision: str = "69"
down_revision: str | None = "68"
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,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (comment_id, mentioned_user_id)
)
"""
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_comment_id ON chat_comment_mentions(comment_id)"
)
def downgrade() -> None:
"""Drop chat_comment_mentions table."""
op.execute(
"""
DROP TABLE IF EXISTS chat_comment_mentions;
"""
)

View file

@ -0,0 +1,94 @@
"""Add comments permissions to existing roles
Revision ID: 70
Revises: 69
Create Date: 2024-01-16
"""
from sqlalchemy import text
from alembic import op
# revision identifiers, used by Alembic.
revision = "70"
down_revision = "69"
branch_labels = None
depends_on = None
def upgrade():
connection = op.get_bind()
# Add comments:create to Admin, Editor, Viewer roles (if not already present)
connection.execute(
text(
"""
UPDATE search_space_roles
SET permissions = array_append(permissions, 'comments:create')
WHERE name IN ('Admin', 'Editor', 'Viewer')
AND NOT ('comments:create' = ANY(permissions))
"""
)
)
# Add comments:read to Admin, Editor, Viewer roles (if not already present)
connection.execute(
text(
"""
UPDATE search_space_roles
SET permissions = array_append(permissions, 'comments:read')
WHERE name IN ('Admin', 'Editor', 'Viewer')
AND NOT ('comments:read' = ANY(permissions))
"""
)
)
# Add comments:delete to Admin roles only (if not already present)
connection.execute(
text(
"""
UPDATE search_space_roles
SET permissions = array_append(permissions, 'comments:delete')
WHERE name = 'Admin'
AND NOT ('comments:delete' = ANY(permissions))
"""
)
)
def downgrade():
connection = op.get_bind()
# Remove comments:create from Admin, Editor, Viewer roles
connection.execute(
text(
"""
UPDATE search_space_roles
SET permissions = array_remove(permissions, 'comments:create')
WHERE name IN ('Admin', 'Editor', 'Viewer')
"""
)
)
# Remove comments:read from Admin, Editor, Viewer roles
connection.execute(
text(
"""
UPDATE search_space_roles
SET permissions = array_remove(permissions, 'comments:read')
WHERE name IN ('Admin', 'Editor', 'Viewer')
"""
)
)
# Remove comments:delete from Admin roles only
connection.execute(
text(
"""
UPDATE search_space_roles
SET permissions = array_remove(permissions, 'comments:delete')
WHERE name = 'Admin'
"""
)
)

View file

@ -0,0 +1,59 @@
"""Add Electric SQL replication for chat_comment_mentions table
Revision ID: 71
Revises: 70
Enables Electric SQL replication for the chat_comment_mentions table to support
real-time live updates for mentions.
"""
from collections.abc import Sequence
from alembic import op
revision: str = "71"
down_revision: str | None = "70"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Enable Electric SQL replication for chat_comment_mentions table."""
op.execute("ALTER TABLE chat_comment_mentions REPLICA IDENTITY FULL;")
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'electric_publication_default'
AND tablename = 'chat_comment_mentions'
) THEN
ALTER PUBLICATION electric_publication_default ADD TABLE chat_comment_mentions;
END IF;
END
$$;
"""
)
def downgrade() -> None:
"""Remove chat_comment_mentions from Electric SQL replication."""
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'electric_publication_default'
AND tablename = 'chat_comment_mentions'
) THEN
ALTER PUBLICATION electric_publication_default DROP TABLE chat_comment_mentions;
END IF;
END
$$;
"""
)
op.execute("ALTER TABLE chat_comment_mentions REPLICA IDENTITY DEFAULT;")

View file

@ -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"
@ -209,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,
@ -252,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,
@ -279,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)
@ -424,6 +439,84 @@ 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 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,
)
# Relationships
comment = relationship("ChatComment", back_populates="mentions")
mentioned_user = relationship("User")
class Document(BaseModel, TimestampMixin):

View file

@ -3,6 +3,7 @@ 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 .confluence_add_connector_route import router as confluence_add_connector_router
@ -43,6 +44,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(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)

View file

@ -0,0 +1,95 @@
"""
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.chat_comments import (
CommentCreateRequest,
CommentListResponse,
CommentReplyResponse,
CommentResponse,
CommentUpdateRequest,
MentionListResponse,
)
from app.services.chat_comments_service import (
create_comment,
create_reply,
delete_comment,
get_comments_for_message,
get_user_mentions,
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)
# =============================================================================
# Mention Routes
# =============================================================================
@router.get("/mentions", response_model=MentionListResponse)
async def list_mentions(
search_space_id: int | None = None,
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)

View file

@ -19,13 +19,14 @@ from datetime import UTC, datetime
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from fastapi.responses import StreamingResponse
from sqlalchemy import or_
from sqlalchemy import func, or_
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from app.db import (
ChatComment,
ChatVisibility,
NewChatMessage,
NewChatMessageRole,
@ -508,7 +509,19 @@ async def get_thread_full(
# Check thread-level access based on visibility
await check_thread_access(session, thread, user)
return thread
# Check if thread has any comments
comment_count = await session.scalar(
select(func.count())
.select_from(ChatComment)
.join(NewChatMessage, ChatComment.message_id == NewChatMessage.id)
.where(NewChatMessage.thread_id == thread.id)
)
return {
**thread.__dict__,
"messages": thread.messages,
"has_comments": (comment_count or 0) > 0,
}
except HTTPException:
raise

View file

@ -452,6 +452,8 @@ async def list_members(
"created_at": membership.created_at,
"role": membership.role,
"user_email": member_user.email if member_user else None,
"user_display_name": member_user.display_name if member_user else None,
"user_avatar_url": member_user.avatar_url if member_user else None,
}
response.append(membership_dict)

View file

@ -0,0 +1,129 @@
"""
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 = False
can_delete: bool = False
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 = False
can_delete: bool = False
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
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]
total_count: int

View file

@ -105,6 +105,7 @@ class NewChatThreadWithMessages(NewChatThreadRead):
"""Schema for reading a thread with its messages."""
messages: list[NewChatMessageRead] = []
has_comments: bool = False
# =============================================================================

View file

@ -73,8 +73,10 @@ class MembershipRead(BaseModel):
created_at: datetime
# Nested role info
role: RoleRead | None = None
# User email (populated separately)
# User details (populated separately)
user_email: str | None = None
user_display_name: str | None = None
user_avatar_url: str | None = None
class Config:
from_attributes = True

View file

@ -0,0 +1,733 @@
"""
Service layer for chat comments and mentions.
"""
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.db import (
ChatComment,
ChatCommentMention,
NewChatMessage,
NewChatMessageRole,
NewChatThread,
Permission,
SearchSpaceMembership,
User,
has_permission,
)
from app.schemas.chat_comments import (
AuthorResponse,
CommentListResponse,
CommentReplyResponse,
CommentResponse,
MentionCommentResponse,
MentionContextResponse,
MentionListResponse,
MentionResponse,
)
from app.services.notification_service import NotificationService
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,
) -> dict[UUID, int]:
"""
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
Returns:
Dictionary mapping mentioned user UUID to their mention record ID
"""
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 and collect their IDs
mentions_map: dict[UUID, int] = {}
for user_id in valid_member_ids:
mention = ChatCommentMention(
comment_id=comment_id,
mentioned_user_id=user_id,
)
session.add(mention)
await session.flush()
mentions_map[user_id] = mention.id
return mentions_map
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 COMMENTS_READ 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")
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()
# 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
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,
)
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=render_mentions(reply.content, user_names),
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=render_mentions(comment.content, user_names),
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),
)
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.flush()
# Process mentions - returns map of user_id -> mention_id
mentions_map = await process_mentions(session, comment.id, content, search_space_id)
await session.commit()
await session.refresh(comment)
# Fetch user names for rendering mentions (reuse mentions_map keys)
user_names = await get_user_names_for_mentions(session, set(mentions_map.keys()))
# Create notifications for mentioned users (excluding author)
thread = message.thread
author_name = user.display_name or user.email
content_preview = render_mentions(content, user_names)
for mentioned_user_id, mention_id in mentions_map.items():
if mentioned_user_id == user.id:
continue # Don't notify yourself
await NotificationService.mention.notify_new_mention(
session=session,
mentioned_user_id=mentioned_user_id,
mention_id=mention_id,
comment_id=comment.id,
message_id=message_id,
thread_id=thread.id,
thread_title=thread.title or "Untitled thread",
author_id=str(user.id),
author_name=author_name,
content_preview=content_preview[:200],
search_space_id=search_space_id,
)
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=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,
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.flush()
# Process mentions - returns map of user_id -> mention_id
mentions_map = await process_mentions(session, reply.id, content, search_space_id)
await session.commit()
await session.refresh(reply)
# Fetch user names for rendering mentions (reuse mentions_map keys)
user_names = await get_user_names_for_mentions(session, set(mentions_map.keys()))
# Create notifications for mentioned users (excluding author)
thread = parent_comment.message.thread
author_name = user.display_name or user.email
content_preview = render_mentions(content, user_names)
for mentioned_user_id, mention_id in mentions_map.items():
if mentioned_user_id == user.id:
continue # Don't notify yourself
await NotificationService.mention.notify_new_mention(
session=session,
mentioned_user_id=mentioned_user_id,
mention_id=mention_id,
comment_id=reply.id,
message_id=parent_comment.message_id,
thread_id=thread.id,
thread_title=thread.title or "Untitled thread",
author_id=str(user.id),
author_name=author_name,
content_preview=content_preview[:200],
search_space_id=search_space_id,
)
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=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,
)
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),
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")
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 and collect their IDs for notifications
new_mentions_map: dict[UUID, int] = {}
for user_id in mentions_to_add:
mention = ChatCommentMention(
comment_id=comment_id,
mentioned_user_id=user_id,
)
session.add(mention)
await session.flush()
new_mentions_map[user_id] = mention.id
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)
# Create notifications for newly added mentions (excluding author)
if new_mentions_map:
thread = comment.message.thread
author_name = user.display_name or user.email
content_preview = render_mentions(content, user_names)
for mentioned_user_id, mention_id in new_mentions_map.items():
if mentioned_user_id == user.id:
continue # Don't notify yourself
await NotificationService.mention.notify_new_mention(
session=session,
mentioned_user_id=mentioned_user_id,
mention_id=mention_id,
comment_id=comment_id,
message_id=comment.message_id,
thread_id=thread.id,
thread_title=thread.title or "Untitled thread",
author_id=str(user.id),
author_name=author_name,
content_preview=content_preview[:200],
search_space_id=search_space_id,
)
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=render_mentions(content, user_names),
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,
)
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}
async def get_user_mentions(
session: AsyncSession,
user: User,
search_space_id: int | None = None,
) -> 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
Returns:
MentionListResponse with mentions and total 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),
selectinload(ChatCommentMention.comment).selectinload(ChatComment.message),
)
.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)
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 = {}
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,
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,
total_count=len(mentions),
)

View file

@ -617,12 +617,83 @@ class DocumentProcessingNotificationHandler(BaseNotificationHandler):
)
class MentionNotificationHandler(BaseNotificationHandler):
"""Handler for new mention notifications."""
def __init__(self):
super().__init__("new_mention")
async def notify_new_mention(
self,
session: AsyncSession,
mentioned_user_id: UUID,
mention_id: int,
comment_id: int,
message_id: int,
thread_id: int,
thread_title: str,
author_id: str,
author_name: str,
content_preview: str,
search_space_id: int,
) -> Notification:
"""
Create notification when a user is @mentioned in a comment.
Args:
session: Database session
mentioned_user_id: User who was mentioned
mention_id: ID of the mention record
comment_id: ID of the comment containing the mention
message_id: ID of the message being commented on
thread_id: ID of the chat thread
thread_title: Title of the chat thread
author_id: ID of the comment author
author_name: Display name of the comment author
content_preview: First ~100 chars of the comment
search_space_id: Search space ID
Returns:
Notification: The created notification
"""
title = f"{author_name} mentioned you"
message = content_preview[:100] + ("..." if len(content_preview) > 100 else "")
metadata = {
"mention_id": mention_id,
"comment_id": comment_id,
"message_id": message_id,
"thread_id": thread_id,
"thread_title": thread_title,
"author_id": author_id,
"author_name": author_name,
"content_preview": content_preview[:200],
}
notification = Notification(
user_id=mentioned_user_id,
search_space_id=search_space_id,
type=self.notification_type,
title=title,
message=message,
notification_metadata=metadata,
)
session.add(notification)
await session.commit()
await session.refresh(notification)
logger.info(
f"Created new_mention notification {notification.id} for user {mentioned_user_id}"
)
return notification
class NotificationService:
"""Service for creating and managing notifications that sync via Electric SQL."""
# Handler instances
connector_indexing = ConnectorIndexingNotificationHandler()
document_processing = DocumentProcessingNotificationHandler()
mention = MentionNotificationHandler()
@staticmethod
async def create_notification(

View file

@ -0,0 +1,64 @@
"""
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.
Uses curly braces as delimiters for unambiguous frontend parsing.
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)

View file

@ -8,10 +8,11 @@ import {
} from "@assistant-ui/react";
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { useParams } from "next/navigation";
import { useParams, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import {
type MentionedDocumentInfo,
mentionedDocumentIdsAtom,
@ -251,6 +252,7 @@ export default function NewChatPage() {
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
const hydratePlanState = useSetAtom(hydratePlanStateAtom);
const setCurrentThreadState = useSetAtom(currentThreadAtom);
// Get current user for author info in shared chats
const { data: currentUser } = useAtomValue(currentUserAtom);
@ -365,6 +367,48 @@ export default function NewChatPage() {
initializeThread();
}, [initializeThread]);
// Handle scroll to comment from URL query params (e.g., from notification click)
const searchParams = useSearchParams();
const targetCommentId = searchParams.get("commentId");
useEffect(() => {
if (!targetCommentId || isInitializing || messages.length === 0) return;
const tryScroll = () => {
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
return true;
}
return false;
};
// Try immediately
if (tryScroll()) return;
// Retry every 200ms for up to 10 seconds
const intervalId = setInterval(() => {
if (tryScroll()) clearInterval(intervalId);
}, 200);
const timeoutId = setTimeout(() => clearInterval(intervalId), 10000);
return () => {
clearInterval(intervalId);
clearTimeout(timeoutId);
};
}, [targetCommentId, isInitializing, messages.length]);
// Sync current thread state to atom
useEffect(() => {
setCurrentThreadState({
id: currentThread?.id ?? null,
visibility: currentThread?.visibility ?? null,
hasComments: currentThread?.has_comments ?? false,
addingCommentToMessageId: null,
});
}, [currentThread, setCurrentThreadState]);
// Cancel ongoing request
const cancelRun = useCallback(async () => {
if (abortControllerRef.current) {
@ -842,10 +886,32 @@ export default function NewChatPage() {
// Persist assistant message (with thinking steps for restoration on refresh)
const finalContent = buildContentForPersistence();
if (contentParts.length > 0) {
appendMessage(currentThreadId, {
role: "assistant",
content: finalContent,
}).catch((err) => console.error("Failed to persist assistant message:", err));
try {
const savedMessage = await appendMessage(currentThreadId, {
role: "assistant",
content: finalContent,
});
// Update message ID from temporary to database ID so comments work immediately
const newMsgId = `msg-${savedMessage.id}`;
setMessages((prev) =>
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
);
// Also update thinking steps map with new ID
setMessageThinkingSteps((prev) => {
const steps = prev.get(assistantMsgId);
if (steps) {
const newMap = new Map(prev);
newMap.delete(assistantMsgId);
newMap.set(newMsgId, steps);
return newMap;
}
return prev;
});
} catch (err) {
console.error("Failed to persist assistant message:", err);
}
// Track successful response
trackChatResponseReceived(searchSpaceId, currentThreadId);
@ -860,10 +926,20 @@ export default function NewChatPage() {
);
if (hasContent && currentThreadId) {
const partialContent = buildContentForPersistence();
appendMessage(currentThreadId, {
role: "assistant",
content: partialContent,
}).catch((err) => console.error("Failed to persist partial assistant message:", err));
try {
const savedMessage = await appendMessage(currentThreadId, {
role: "assistant",
content: partialContent,
});
// Update message ID from temporary to database ID
const newMsgId = `msg-${savedMessage.id}`;
setMessages((prev) =>
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
);
} catch (err) {
console.error("Failed to persist partial assistant message:", err);
}
}
return;
}

View file

@ -157,5 +157,33 @@ button {
cursor: pointer;
}
/* Custom scrollbar styles */
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground) / 0.2) transparent;
}
.scrollbar-thin:hover {
scrollbar-color: hsl(var(--muted-foreground) / 0.4) transparent;
}
/* Webkit scrollbar styles */
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground) / 0.2);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.4);
}
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
@source '../node_modules/streamdown/dist/*.js';

View file

@ -0,0 +1,72 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
CreateCommentRequest,
CreateReplyRequest,
DeleteCommentRequest,
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");
},
}));

View file

@ -0,0 +1,52 @@
import { atom } from "jotai";
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
// TODO: Update `hasComments` to true when the first comment is created on a thread.
// Currently it only updates on thread load. The gutter still works because
// `addingCommentToMessageId` keeps it open, but the state is technically stale.
// TODO: Reset `addingCommentToMessageId` to null after a comment is successfully created.
// Currently it stays set until navigation or clicking another message's bubble.
// Not causing issues since panel visibility is driven by per-message comment count.
// TODO: Consider calling `resetCurrentThreadAtom` when unmounting the chat page
// for explicit cleanup, though React navigation handles this implicitly.
interface CurrentThreadState {
id: number | null;
visibility: ChatVisibility | null;
hasComments: boolean;
addingCommentToMessageId: number | null;
}
const initialState: CurrentThreadState = {
id: null,
visibility: null,
hasComments: false,
addingCommentToMessageId: null,
};
export const currentThreadAtom = atom<CurrentThreadState>(initialState);
export const commentsEnabledAtom = atom(
(get) => get(currentThreadAtom).visibility === "SEARCH_SPACE"
);
export const showCommentsGutterAtom = atom((get) => {
const thread = get(currentThreadAtom);
return (
thread.visibility === "SEARCH_SPACE" &&
(thread.hasComments || thread.addingCommentToMessageId !== null)
);
});
export const addingCommentToMessageIdAtom = atom(
(get) => get(currentThreadAtom).addingCommentToMessageId,
(get, set, messageId: number | null) => {
set(currentThreadAtom, { ...get(currentThreadAtom), addingCommentToMessageId: messageId });
}
);
export const resetCurrentThreadAtom = atom(null, (_, set) => {
set(currentThreadAtom, initialState);
});

View file

@ -5,9 +5,15 @@ import {
MessagePrimitive,
useAssistantState,
} from "@assistant-ui/react";
import { useAtom, useAtomValue } from "jotai";
import { CheckIcon, CopyIcon, DownloadIcon, RefreshCwIcon } from "lucide-react";
import type { FC } from "react";
import { useContext } from "react";
import { useContext, useEffect, useRef, useState } from "react";
import {
addingCommentToMessageIdAtom,
commentsEnabledAtom,
} from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { BranchPicker } from "@/components/assistant-ui/branch-picker";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import {
@ -16,6 +22,9 @@ import {
} from "@/components/assistant-ui/thinking-steps";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
import { CommentTrigger } from "@/components/chat-comments/comment-trigger/comment-trigger";
import { useComments } from "@/hooks/use-comments";
export const MessageError: FC = () => {
return (
@ -76,13 +85,89 @@ const AssistantMessageInner: FC = () => {
);
};
function parseMessageId(assistantUiMessageId: string | undefined): number | null {
if (!assistantUiMessageId) return null;
const match = assistantUiMessageId.match(/^msg-(\d+)$/);
return match ? Number.parseInt(match[1], 10) : null;
}
export const AssistantMessage: FC = () => {
const [messageHeight, setMessageHeight] = useState<number | undefined>(undefined);
const messageRef = useRef<HTMLDivElement>(null);
const messageId = useAssistantState(({ message }) => message?.id);
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const dbMessageId = parseMessageId(messageId);
const commentsEnabled = useAtomValue(commentsEnabledAtom);
const [addingCommentToMessageId, setAddingCommentToMessageId] = useAtom(
addingCommentToMessageIdAtom
);
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false);
const isMessageStreaming = isThreadRunning && isLastMessage;
const { data: commentsData } = useComments({
messageId: dbMessageId ?? 0,
enabled: !!dbMessageId,
});
const hasComments = (commentsData?.total_count ?? 0) > 0;
const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId;
const showCommentPanel = hasComments || isAddingComment;
const handleToggleAddComment = () => {
if (!dbMessageId) return;
setAddingCommentToMessageId(isAddingComment ? null : dbMessageId);
};
useEffect(() => {
if (!messageRef.current) return;
const el = messageRef.current;
const update = () => setMessageHeight(el.offsetHeight);
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<MessagePrimitive.Root
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
ref={messageRef}
className="aui-assistant-message-root group fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
data-role="assistant"
>
<AssistantMessageInner />
{searchSpaceId && commentsEnabled && !isMessageStreaming && (
<div className="absolute left-full top-0 ml-4 hidden lg:block w-72">
<div
className={`sticky top-3 ${showCommentPanel ? "opacity-100" : "opacity-0 group-hover:opacity-100"} transition-opacity`}
>
{!hasComments && (
<CommentTrigger
commentCount={0}
isOpen={isAddingComment}
onClick={handleToggleAddComment}
disabled={!dbMessageId}
/>
)}
{showCommentPanel && dbMessageId && (
<div
className={
hasComments ? "" : "mt-2 animate-in fade-in slide-in-from-top-2 duration-200"
}
>
<CommentPanelContainer
messageId={dbMessageId}
isOpen={true}
maxHeight={messageHeight}
/>
</div>
)}
</div>
</div>
)}
</MessagePrimitive.Root>
);
};

View file

@ -26,6 +26,7 @@ import {
import { useParams } from "next/navigation";
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
import {
mentionedDocumentIdsAtom,
mentionedDocumentsAtom,
@ -36,6 +37,7 @@ import {
newLLMConfigsAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import {
@ -59,57 +61,63 @@ import { Button } from "@/components/ui/button";
import type { Document } from "@/contracts/types/document.types";
import { cn } from "@/lib/utils";
/**
* Props for the Thread component
*/
interface ThreadProps {
messageThinkingSteps?: Map<string, ThinkingStep[]>;
/** Optional header component to render at the top of the viewport (sticky) */
header?: React.ReactNode;
}
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map(), header }) => {
return (
<ThinkingStepsContext.Provider value={messageThinkingSteps}>
<ThreadPrimitive.Root
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-background"
style={{
["--thread-max-width" as string]: "44rem",
}}
>
<ThreadPrimitive.Viewport
turnAnchor="top"
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
>
{/* Optional sticky header for model selector etc. */}
{header && <div className="sticky top-0 z-10 mb-4">{header}</div>}
<AssistantIf condition={({ thread }) => thread.isEmpty}>
<ThreadWelcome />
</AssistantIf>
<ThreadPrimitive.Messages
components={{
UserMessage,
EditComposer,
AssistantMessage,
}}
/>
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6">
<ThreadScrollToBottom />
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
<Composer />
</div>
</AssistantIf>
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
<ThreadContent header={header} />
</ThinkingStepsContext.Provider>
);
};
const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
const showGutter = useAtomValue(showCommentsGutterAtom);
return (
<ThreadPrimitive.Root
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-background"
style={{
["--thread-max-width" as string]: "44rem",
}}
>
<ThreadPrimitive.Viewport
turnAnchor="top"
className={cn(
"aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out",
showGutter && "lg:pr-30"
)}
>
{header && <div className="sticky top-0 z-10 mb-4">{header}</div>}
<AssistantIf condition={({ thread }) => thread.isEmpty}>
<ThreadWelcome />
</AssistantIf>
<ThreadPrimitive.Messages
components={{
UserMessage,
EditComposer,
AssistantMessage,
}}
/>
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6">
<ThreadScrollToBottom />
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
<Composer />
</div>
</AssistantIf>
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
);
};
const ThreadScrollToBottom: FC = () => {
return (
<ThreadPrimitive.ScrollToBottom asChild>
@ -579,17 +587,6 @@ const AssistantMessageInner: FC = () => {
);
};
const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
data-role="assistant"
>
<AssistantMessageInner />
</MessagePrimitive.Root>
);
};
const AssistantActionBar: FC = () => {
return (
<ActionBarPrimitive.Root

View file

@ -0,0 +1,303 @@
"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,
initialValue = "",
}: CommentComposerProps) {
const [displayContent, setDisplayContent] = useState(initialValue);
const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]);
const [mentionsInitialized, setMentionsInitialized] = useState(false);
const [mentionState, setMentionState] = useState<MentionState>({
isActive: false,
query: "",
startIndex: 0,
});
const [highlightedIndex, setHighlightedIndex] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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([]);
};
// Pre-populate insertedMentions from initialValue when members are loaded
useEffect(() => {
if (mentionsInitialized || !initialValue || members.length === 0) return;
const mentionPattern = /@([^\s@]+(?:\s+[^\s@]+)*?)(?=\s|$|[.,!?;:]|@)/g;
const foundMentions: InsertedMention[] = [];
let match: RegExpExecArray | null;
while ((match = mentionPattern.exec(initialValue)) !== null) {
const displayName = match[1];
const member = members.find(
(m) => m.displayName === displayName || m.email.split("@")[0] === displayName
);
if (member) {
const exists = foundMentions.some((m) => m.id === member.id);
if (!exists) {
foundMentions.push({ id: member.id, displayName });
}
}
}
if (foundMentions.length > 0) {
setInsertedMentions(foundMentions);
}
setMentionsInitialized(true);
}, [initialValue, members, mentionsInitialized]);
useEffect(() => {
if (autoFocus && textareaRef.current) {
textareaRef.current.focus();
}
}, [autoFocus]);
const canSubmit = displayContent.trim().length > 0 && !isSubmitting;
return (
<div className="flex flex-col gap-2">
<Popover
open={mentionState.isActive}
onOpenChange={(open) => !open && closeMentionPicker()}
modal={false}
>
<PopoverAnchor asChild>
<Textarea
ref={textareaRef}
value={displayContent}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="min-h-[80px] resize-none"
disabled={isSubmitting}
/>
</PopoverAnchor>
<PopoverContent
side="top"
align="start"
sideOffset={4}
collisionPadding={8}
className="w-72 p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<MemberMentionPicker
members={members}
query={mentionState.query}
highlightedIndex={highlightedIndex}
isLoading={membersLoading}
onSelect={insertMention}
onHighlightChange={setHighlightedIndex}
/>
</PopoverContent>
</Popover>
<div className="flex items-center justify-end gap-2">
{onCancel && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCancel}
disabled={isSubmitting}
>
<X className="mr-1 size-4" />
Cancel
</Button>
)}
<Button
type="button"
size="sm"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(!canSubmit && "opacity-50")}
>
<Send className="mr-1 size-4" />
{submitLabel}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,24 @@
import type { MemberOption } from "../member-mention-picker/types";
export interface CommentComposerProps {
members: MemberOption[];
membersLoading?: boolean;
placeholder?: string;
submitLabel?: string;
isSubmitting?: boolean;
onSubmit: (content: string) => void;
onCancel?: () => void;
autoFocus?: boolean;
initialValue?: string;
}
export interface MentionState {
isActive: boolean;
query: string;
startIndex: number;
}
export interface InsertedMention {
id: string;
displayName: string;
}

View file

@ -0,0 +1,45 @@
"use client";
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { CommentActionsProps } from "./types";
export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: CommentActionsProps) {
if (!canEdit && !canDelete) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canEdit && (
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 size-4" />
Edit
</DropdownMenuItem>
)}
{canDelete && (
<DropdownMenuItem onClick={onDelete} className="text-destructive">
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,196 @@
"use client";
import { useAtom } from "jotai";
import { MessageSquare } from "lucide-react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { CommentComposer } from "../comment-composer/comment-composer";
import { CommentActions } from "./comment-actions";
import type { CommentItemProps } from "./types";
function getInitials(name: string | null, email: string): string {
if (name) {
return name
.split(" ")
.map((part) => part[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
return email[0].toUpperCase();
}
function formatTimestamp(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
const timeStr = date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
if (diffMins < 1) {
return "Just now";
}
if (diffMins < 60) {
return `${diffMins}m ago`;
}
if (diffHours < 24 && date.getDate() === now.getDate()) {
return `Today at ${timeStr}`;
}
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (date.getDate() === yesterday.getDate() && diffDays < 2) {
return `Yesterday at ${timeStr}`;
}
if (diffDays < 7) {
const dayName = date.toLocaleDateString("en-US", { weekday: "long" });
return `${dayName} at ${timeStr}`;
}
return (
date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
}) + ` at ${timeStr}`
);
}
export function convertRenderedToDisplay(contentRendered: string): string {
// Convert @{DisplayName} format to @DisplayName for editing
return contentRendered.replace(/@\{([^}]+)\}/g, "@$1");
}
function renderMentions(content: string): React.ReactNode {
// Match @{DisplayName} format from backend
const mentionPattern = /@\{([^}]+)\}/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = mentionPattern.exec(content)) !== null) {
if (match.index > lastIndex) {
parts.push(content.slice(lastIndex, match.index));
}
// Display as @DisplayName (without curly braces)
parts.push(
<span key={match.index} className="rounded bg-primary/10 px-1 font-medium text-primary">
@{match[1]}
</span>
);
lastIndex = match.index + match[0].length;
}
if (lastIndex < content.length) {
parts.push(content.slice(lastIndex));
}
return parts.length > 0 ? parts : content;
}
export function CommentItem({
comment,
onEdit,
onEditSubmit,
onEditCancel,
onDelete,
onReply,
isReply = false,
isEditing = false,
isSubmitting = false,
members = [],
membersLoading = false,
}: CommentItemProps) {
const [{ data: currentUser }] = useAtom(currentUserAtom);
const isCurrentUser = currentUser?.id === comment.author?.id;
const displayName = isCurrentUser
? "Me"
: comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
const email = comment.author?.email || "";
const handleEditSubmit = (content: string) => {
onEditSubmit?.(comment.id, content);
};
return (
<div className={cn("group flex gap-3")} data-comment-id={comment.id}>
<Avatar className="size-8 shrink-0">
{comment.author?.avatarUrl && (
<AvatarImage src={comment.author.avatarUrl} alt={displayName} />
)}
<AvatarFallback className="text-xs">
{getInitials(comment.author?.displayName ?? null, email || "U")}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{displayName}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{formatTimestamp(comment.createdAt)}
</span>
{comment.isEdited && (
<span className="shrink-0 text-xs text-muted-foreground">(edited)</span>
)}
{!isEditing && (
<div className="ml-auto">
<CommentActions
canEdit={comment.canEdit}
canDelete={comment.canDelete}
onEdit={() => onEdit?.(comment.id)}
onDelete={() => onDelete?.(comment.id)}
/>
</div>
)}
</div>
{isEditing ? (
<div className="mt-1">
<CommentComposer
members={members}
membersLoading={membersLoading}
placeholder="Edit your comment..."
submitLabel="Save"
isSubmitting={isSubmitting}
onSubmit={handleEditSubmit}
onCancel={onEditCancel}
initialValue={convertRenderedToDisplay(comment.contentRendered)}
autoFocus
/>
</div>
) : (
<div className="mt-1 text-sm text-foreground whitespace-pre-wrap wrap-break-word">
{renderMentions(comment.contentRendered)}
</div>
)}
{!isReply && onReply && !isEditing && (
<Button
variant="ghost"
size="sm"
className="mt-1 h-7 w-fit px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => onReply(comment.id)}
>
<MessageSquare className="mr-1 size-3" />
Reply
</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,44 @@
export interface CommentAuthor {
id: string;
displayName: string | null;
email: string;
avatarUrl?: string | null;
}
export interface CommentData {
id: number;
content: string;
contentRendered: string;
author: CommentAuthor | null;
createdAt: string;
updatedAt: string;
isEdited: boolean;
canEdit: boolean;
canDelete: boolean;
}
export interface CommentItemProps {
comment: CommentData;
onEdit?: (commentId: number) => void;
onEditSubmit?: (commentId: number, content: string) => void;
onEditCancel?: () => void;
onDelete?: (commentId: number) => void;
onReply?: (commentId: number) => void;
isReply?: boolean;
isEditing?: boolean;
isSubmitting?: boolean;
members?: Array<{
id: string;
displayName: string | null;
email: string;
avatarUrl?: string | null;
}>;
membersLoading?: boolean;
}
export interface CommentActionsProps {
canEdit: boolean;
canDelete: boolean;
onEdit: () => void;
onDelete: () => void;
}

View file

@ -0,0 +1,85 @@
"use client";
import { useAtom } from "jotai";
import { useMemo } from "react";
import {
createCommentMutationAtom,
createReplyMutationAtom,
deleteCommentMutationAtom,
updateCommentMutationAtom,
} from "@/atoms/chat-comments/comments-mutation.atoms";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { useComments } from "@/hooks/use-comments";
import { CommentPanel } from "../comment-panel/comment-panel";
import type { CommentPanelContainerProps } from "./types";
import { transformComment, transformMember } from "./utils";
export function CommentPanelContainer({
messageId,
isOpen,
maxHeight,
}: CommentPanelContainerProps) {
const { data: commentsData, isLoading: isCommentsLoading } = useComments({
messageId,
enabled: isOpen,
});
const [{ data: membersData, isLoading: isMembersLoading }] = useAtom(membersAtom);
const [{ data: currentUser }] = useAtom(currentUserAtom);
const [{ mutate: createComment, isPending: isCreating }] = useAtom(createCommentMutationAtom);
const [{ mutate: createReply, isPending: isCreatingReply }] = useAtom(createReplyMutationAtom);
const [{ mutate: updateComment, isPending: isUpdating }] = useAtom(updateCommentMutationAtom);
const [{ mutate: deleteComment, isPending: isDeleting }] = useAtom(deleteCommentMutationAtom);
const commentThreads = useMemo(() => {
if (!commentsData?.comments) return [];
return commentsData.comments.map(transformComment);
}, [commentsData]);
const members = useMemo(() => {
if (!membersData) return [];
const allMembers = membersData.map(transformMember);
// Filter out current user from mention picker
if (currentUser?.id) {
return allMembers.filter((member) => member.id !== currentUser.id);
}
return allMembers;
}, [membersData, currentUser?.id]);
const isSubmitting = isCreating || isCreatingReply || isUpdating || isDeleting;
const handleCreateComment = (content: string) => {
createComment({ message_id: messageId, content });
};
const handleCreateReply = (commentId: number, content: string) => {
createReply({ comment_id: commentId, content, message_id: messageId });
};
const handleEditComment = (commentId: number, content: string) => {
updateComment({ comment_id: commentId, content, message_id: messageId });
};
const handleDeleteComment = (commentId: number) => {
deleteComment({ comment_id: commentId, message_id: messageId });
};
if (!isOpen) return null;
return (
<CommentPanel
threads={commentThreads}
members={members}
membersLoading={isMembersLoading}
isLoading={isCommentsLoading}
onCreateComment={handleCreateComment}
onCreateReply={handleCreateReply}
onEditComment={handleEditComment}
onDeleteComment={handleDeleteComment}
isSubmitting={isSubmitting}
maxHeight={maxHeight}
/>
);
}

View file

@ -0,0 +1,5 @@
export interface CommentPanelContainerProps {
messageId: number;
isOpen: boolean;
maxHeight?: number;
}

View file

@ -0,0 +1,55 @@
import type { Comment, CommentReply } from "@/contracts/types/chat-comments.types";
import type { Membership } from "@/contracts/types/members.types";
import type { CommentData } from "../comment-item/types";
import type { CommentThreadData } from "../comment-thread/types";
import type { MemberOption } from "../member-mention-picker/types";
export function transformAuthor(author: Comment["author"]): CommentData["author"] {
if (!author) return null;
return {
id: author.id,
displayName: author.display_name,
email: author.email,
avatarUrl: author.avatar_url,
};
}
export function transformReply(reply: CommentReply): CommentData {
return {
id: reply.id,
content: reply.content,
contentRendered: reply.content_rendered,
author: transformAuthor(reply.author),
createdAt: reply.created_at,
updatedAt: reply.updated_at,
isEdited: reply.is_edited,
canEdit: reply.can_edit,
canDelete: reply.can_delete,
};
}
export function transformComment(comment: Comment): CommentThreadData {
return {
id: comment.id,
messageId: comment.message_id,
content: comment.content,
contentRendered: comment.content_rendered,
author: transformAuthor(comment.author),
createdAt: comment.created_at,
updatedAt: comment.updated_at,
isEdited: comment.is_edited,
canEdit: comment.can_edit,
canDelete: comment.can_delete,
replyCount: comment.reply_count,
replies: comment.replies.map(transformReply),
};
}
export function transformMember(membership: Membership): MemberOption {
return {
id: membership.user_id,
displayName: membership.user_display_name ?? null,
email: membership.user_email ?? "",
avatarUrl: membership.user_avatar_url ?? null,
};
}

View file

@ -0,0 +1,110 @@
"use client";
import { MessageSquarePlus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { CommentComposer } from "../comment-composer/comment-composer";
import { CommentThread } from "../comment-thread/comment-thread";
import type { CommentPanelProps } from "./types";
export function CommentPanel({
threads,
members,
membersLoading = false,
isLoading = false,
onCreateComment,
onCreateReply,
onEditComment,
onDeleteComment,
isSubmitting = false,
maxHeight,
}: CommentPanelProps) {
const [isComposerOpen, setIsComposerOpen] = useState(false);
const handleCommentSubmit = (content: string) => {
onCreateComment(content);
setIsComposerOpen(false);
};
const handleComposerCancel = () => {
setIsComposerOpen(false);
};
if (isLoading) {
return (
<div className="flex min-h-[120px] w-96 items-center justify-center rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Loading comments...
</div>
</div>
);
}
const hasThreads = threads.length > 0;
const showEmptyState = !hasThreads && !isComposerOpen;
// Ensure minimum usable height for empty state + composer button
const minHeight = 180;
const effectiveMaxHeight = maxHeight ? Math.max(maxHeight, minHeight) : undefined;
return (
<div
className="flex w-85 flex-col rounded-lg border bg-card"
style={effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined}
>
{hasThreads && (
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
<div className="space-y-4 p-4">
{threads.map((thread) => (
<CommentThread
key={thread.id}
thread={thread}
members={members}
membersLoading={membersLoading}
onCreateReply={onCreateReply}
onEditComment={onEditComment}
onDeleteComment={onDeleteComment}
isSubmitting={isSubmitting}
/>
))}
</div>
</div>
)}
{showEmptyState && (
<div className="flex min-h-[120px] flex-col items-center justify-center gap-2 p-4 text-center">
<MessageSquarePlus className="size-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No comments yet</p>
<p className="text-xs text-muted-foreground/70">
Start a conversation about this response
</p>
</div>
)}
<div className={showEmptyState ? "border-t p-3" : "p-3"}>
{isComposerOpen ? (
<CommentComposer
members={members}
membersLoading={membersLoading}
placeholder="Write a comment..."
submitLabel="Comment"
isSubmitting={isSubmitting}
onSubmit={handleCommentSubmit}
onCancel={handleComposerCancel}
autoFocus
/>
) : (
<Button
variant="ghost"
className="w-full justify-start text-muted-foreground hover:text-foreground"
onClick={() => setIsComposerOpen(true)}
>
<MessageSquarePlus className="mr-2 size-4" />
Add a comment...
</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,15 @@
import type { CommentThreadData } from "../comment-thread/types";
import type { MemberOption } from "../member-mention-picker/types";
export interface CommentPanelProps {
threads: CommentThreadData[];
members: MemberOption[];
membersLoading?: boolean;
isLoading?: boolean;
onCreateComment: (content: string) => void;
onCreateReply: (commentId: number, content: string) => void;
onEditComment: (commentId: number, content: string) => void;
onDeleteComment: (commentId: number) => void;
isSubmitting?: boolean;
maxHeight?: number;
}

View file

@ -0,0 +1,166 @@
"use client";
import { ChevronDown, ChevronRight, MessageSquare } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { CommentComposer } from "../comment-composer/comment-composer";
import { CommentItem } from "../comment-item/comment-item";
import type { CommentThreadProps } from "./types";
export function CommentThread({
thread,
members,
membersLoading = false,
onCreateReply,
onEditComment,
onDeleteComment,
isSubmitting = false,
}: CommentThreadProps) {
const [isRepliesExpanded, setIsRepliesExpanded] = useState(true);
const [isReplyComposerOpen, setIsReplyComposerOpen] = useState(false);
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const parentComment = {
id: thread.id,
content: thread.content,
contentRendered: thread.contentRendered,
author: thread.author,
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
isEdited: thread.isEdited,
canEdit: thread.canEdit,
canDelete: thread.canDelete,
};
const handleReply = () => {
setIsReplyComposerOpen(true);
setIsRepliesExpanded(true);
};
const handleReplySubmit = (content: string) => {
onCreateReply(thread.id, content);
setIsReplyComposerOpen(false);
};
const handleReplyCancel = () => {
setIsReplyComposerOpen(false);
};
const handleEditStart = (commentId: number) => {
setEditingCommentId(commentId);
};
const handleEditSubmit = (commentId: number, content: string) => {
onEditComment(commentId, content);
setEditingCommentId(null);
};
const handleEditCancel = () => {
setEditingCommentId(null);
};
const hasReplies = thread.replies.length > 0;
const showReplies = thread.replies.length === 1 || isRepliesExpanded;
return (
<div>
{/* Parent comment */}
<CommentItem
comment={parentComment}
onEdit={handleEditStart}
onEditSubmit={handleEditSubmit}
onEditCancel={handleEditCancel}
onDelete={onDeleteComment}
isEditing={editingCommentId === parentComment.id}
isSubmitting={isSubmitting}
members={members}
membersLoading={membersLoading}
/>
{/* Replies and actions - using flex layout with connector */}
{(hasReplies || isReplyComposerOpen) && (
<div className="flex">
{/* Connector column - vertical line */}
<div className="flex w-7 flex-col items-center">
<div className="w-px flex-1 bg-border" />
</div>
{/* Content column */}
<div className="min-w-0 flex-1 space-y-2 pb-1">
{/* Expand/collapse for multiple replies */}
{thread.replies.length > 1 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setIsRepliesExpanded(!isRepliesExpanded)}
>
{isRepliesExpanded ? (
<ChevronDown className="mr-1 size-3" />
) : (
<ChevronRight className="mr-1 size-3" />
)}
{thread.replies.length} replies
</Button>
)}
{/* Reply items */}
{showReplies && hasReplies && (
<div className="space-y-3 pt-2">
{thread.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
isReply
onEdit={handleEditStart}
onEditSubmit={handleEditSubmit}
onEditCancel={handleEditCancel}
onDelete={onDeleteComment}
isEditing={editingCommentId === reply.id}
isSubmitting={isSubmitting}
members={members}
membersLoading={membersLoading}
/>
))}
</div>
)}
{/* Reply composer or button */}
{isReplyComposerOpen ? (
<>
<div className="pt-3">
<CommentComposer
members={members}
membersLoading={membersLoading}
placeholder="Write a reply..."
submitLabel="Reply"
isSubmitting={isSubmitting}
onSubmit={handleReplySubmit}
onCancel={handleReplyCancel}
autoFocus
/>
</div>
</>
) : (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
<MessageSquare className="mr-1.5 size-3" />
Reply
</Button>
)}
</div>
</div>
)}
{/* Reply button when no replies yet */}
{!hasReplies && !isReplyComposerOpen && (
<div className="ml-7 mt-1">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
<MessageSquare className="mr-1.5 size-3" />
Reply
</Button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,27 @@
import type { CommentData } from "../comment-item/types";
import type { MemberOption } from "../member-mention-picker/types";
export interface CommentThreadData {
id: number;
messageId: number;
content: string;
contentRendered: string;
author: CommentData["author"];
createdAt: string;
updatedAt: string;
isEdited: boolean;
canEdit: boolean;
canDelete: boolean;
replyCount: number;
replies: CommentData[];
}
export interface CommentThreadProps {
thread: CommentThreadData;
members: MemberOption[];
membersLoading?: boolean;
onCreateReply: (commentId: number, content: string) => void;
onEditComment: (commentId: number, content: string) => void;
onDeleteComment: (commentId: number) => void;
isSubmitting?: boolean;
}

View file

@ -0,0 +1,36 @@
"use client";
import { MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { CommentTriggerProps } from "./types";
export function CommentTrigger({ commentCount, isOpen, onClick, disabled }: CommentTriggerProps) {
const hasComments = commentCount > 0;
return (
<Button
variant={hasComments ? "outline" : isOpen ? "secondary" : "ghost"}
size="icon"
disabled={disabled}
className={cn(
"relative size-10 rounded-full transition-all duration-200",
hasComments
? "border-primary/50 bg-primary/5 text-primary hover:bg-primary/10 hover:border-primary"
: isOpen
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
!hasComments && !isOpen && "opacity-0 group-hover:opacity-100",
disabled && "cursor-not-allowed opacity-50"
)}
onClick={onClick}
>
<MessageSquare className={cn("size-5", (hasComments || isOpen) && "fill-current")} />
{hasComments && (
<span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{commentCount > 9 ? "9+" : commentCount}
</span>
)}
</Button>
);
}

View file

@ -0,0 +1,6 @@
export interface CommentTriggerProps {
commentCount: number;
isOpen: boolean;
onClick: () => void;
disabled?: boolean;
}

View file

@ -0,0 +1,49 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import type { MemberMentionItemProps } from "./types";
function getInitials(name: string | null, 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 (
<button
type="button"
className={cn(
"flex w-full items-center gap-3 px-3 py-2 text-left transition-colors",
isHighlighted ? "bg-accent" : "hover:bg-accent/50"
)}
onClick={() => onSelect(member)}
onMouseEnter={onMouseEnter}
>
<Avatar className="size-7">
{member.avatarUrl && <AvatarImage src={member.avatarUrl} alt={displayName} />}
<AvatarFallback className="text-xs">
{getInitials(member.displayName, member.email)}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">{displayName}</span>
<span className="truncate text-xs text-muted-foreground">{member.email}</span>
</div>
</button>
);
}

View file

@ -0,0 +1,55 @@
"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 (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
if (filteredMembers.length === 0) {
return (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{query ? "No members found" : "No members available"}
</div>
);
}
return (
<ScrollArea className="max-h-64">
<div className="py-1">
{filteredMembers.map((member, index) => (
<MemberMentionItem
key={member.id}
member={member}
isHighlighted={index === highlightedIndex}
onSelect={onSelect}
onMouseEnter={() => onHighlightChange(index)}
/>
))}
</div>
</ScrollArea>
);
}

View file

@ -0,0 +1,22 @@
export interface MemberOption {
id: string;
displayName: string | null;
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;
}

View file

@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -12,6 +13,7 @@ import { cn } from "@/lib/utils";
import { useParams } from "next/navigation";
export function NotificationButton() {
const [open, setOpen] = useState(false);
const { data: user } = useAtomValue(currentUserAtom);
const params = useParams();
@ -25,7 +27,7 @@ export function NotificationButton() {
);
return (
<Popover>
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
@ -54,6 +56,7 @@ export function NotificationButton() {
loading={loading}
markAsRead={markAsRead}
markAllAsRead={markAllAsRead}
onClose={() => setOpen(false)}
/>
</PopoverContent>
</Popover>

View file

@ -1,12 +1,14 @@
"use client";
import { Bell, CheckCheck, Loader2, AlertCircle, CheckCircle2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import type { Notification } from "@/hooks/use-notifications";
import { formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils";
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
interface NotificationPopupProps {
notifications: Notification[];
@ -14,6 +16,7 @@ interface NotificationPopupProps {
loading: boolean;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
onClose?: () => void;
}
export function NotificationPopup({
@ -22,15 +25,38 @@ export function NotificationPopup({
loading,
markAsRead,
markAllAsRead,
onClose,
}: NotificationPopupProps) {
const handleMarkAsRead = async (id: number) => {
await markAsRead(id);
};
const router = useRouter();
const handleMarkAllAsRead = async () => {
await markAllAsRead();
};
const handleNotificationClick = async (notification: Notification) => {
if (!notification.read) {
await markAsRead(notification.id);
}
if (notification.type === "new_mention") {
const metadata = notification.metadata as {
thread_id?: number;
comment_id?: number;
};
const searchSpaceId = notification.search_space_id;
const threadId = metadata?.thread_id;
const commentId = metadata?.comment_id;
if (searchSpaceId && threadId) {
const url = commentId
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
onClose?.();
router.push(url);
}
}
};
const formatTime = (dateString: string) => {
try {
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
@ -86,7 +112,7 @@ export function NotificationPopup({
<div key={notification.id}>
<button
type="button"
onClick={() => !notification.read && handleMarkAsRead(notification.id)}
onClick={() => handleNotificationClick(notification)}
className={cn(
"w-full px-4 py-3 text-left hover:bg-accent transition-colors",
!notification.read && "bg-accent/50"
@ -106,7 +132,7 @@ export function NotificationPopup({
</p>
</div>
<p className="text-[11px] text-muted-foreground break-all line-clamp-2">
{notification.message}
{convertRenderedToDisplay(notification.message)}
</p>
<div className="flex items-center justify-between mt-2">
<span className="text-[10px] text-muted-foreground">

View file

@ -0,0 +1,142 @@
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(),
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(),
});
export const getMentionsResponse = z.object({
mentions: z.array(mention),
total_count: z.number(),
});
export type Author = z.infer<typeof author>;
export type CommentReply = z.infer<typeof commentReply>;
export type Comment = z.infer<typeof comment>;
export type MentionContext = z.infer<typeof mentionContext>;
export type MentionComment = z.infer<typeof mentionComment>;
export type Mention = z.infer<typeof mention>;
export type GetCommentsRequest = z.infer<typeof getCommentsRequest>;
export type GetCommentsResponse = z.infer<typeof getCommentsResponse>;
export type CreateCommentRequest = z.infer<typeof createCommentRequest>;
export type CreateCommentResponse = z.infer<typeof createCommentResponse>;
export type CreateReplyRequest = z.infer<typeof createReplyRequest>;
export type CreateReplyResponse = z.infer<typeof createReplyResponse>;
export type UpdateCommentRequest = z.infer<typeof updateCommentRequest>;
export type UpdateCommentResponse = z.infer<typeof updateCommentResponse>;
export type DeleteCommentRequest = z.infer<typeof deleteCommentRequest>;
export type DeleteCommentResponse = z.infer<typeof deleteCommentResponse>;
export type GetMentionsRequest = z.infer<typeof getMentionsRequest>;
export type GetMentionsResponse = z.infer<typeof getMentionsResponse>;

View file

@ -11,6 +11,8 @@ export const membership = z.object({
created_at: z.string(),
role: role.nullable().optional(),
user_email: z.string().nullable().optional(),
user_display_name: z.string().nullable().optional(),
user_avatar_url: z.string().nullable().optional(),
user_is_active: z.boolean().nullable().optional(),
});

View file

@ -5,7 +5,7 @@ import { documentTypeEnum } from "./document.types";
/**
* Notification type enum - matches backend notification types
*/
export const notificationTypeEnum = z.enum(["connector_indexing", "document_processing"]);
export const notificationTypeEnum = z.enum(["connector_indexing", "document_processing", "new_mention"]);
/**
* Notification status enum - used in metadata
@ -68,6 +68,20 @@ export const documentProcessingMetadata = baseNotificationMetadata.extend({
error_message: z.string().nullable().optional(),
});
/**
* New mention metadata schema
*/
export const newMentionMetadata = z.object({
mention_id: z.number(),
comment_id: z.number(),
message_id: z.number(),
thread_id: z.number(),
thread_title: z.string(),
author_id: z.string(),
author_name: z.string(),
content_preview: z.string(),
});
/**
* Union of all notification metadata types
* Use this when the notification type is unknown
@ -75,6 +89,7 @@ export const documentProcessingMetadata = baseNotificationMetadata.extend({
export const notificationMetadata = z.union([
connectorIndexingMetadata,
documentProcessingMetadata,
newMentionMetadata,
baseNotificationMetadata,
]);
@ -107,6 +122,11 @@ export const documentProcessingNotification = notification.extend({
metadata: documentProcessingMetadata,
});
export const newMentionNotification = notification.extend({
type: z.literal("new_mention"),
metadata: newMentionMetadata,
});
// Inferred types
export type NotificationTypeEnum = z.infer<typeof notificationTypeEnum>;
export type NotificationStatusEnum = z.infer<typeof notificationStatusEnum>;
@ -114,7 +134,9 @@ export type DocumentProcessingStageEnum = z.infer<typeof documentProcessingStage
export type BaseNotificationMetadata = z.infer<typeof baseNotificationMetadata>;
export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata>;
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
export type NotificationMetadata = z.infer<typeof notificationMetadata>;
export type Notification = z.infer<typeof notification>;
export type ConnectorIndexingNotification = z.infer<typeof connectorIndexingNotification>;
export type DocumentProcessingNotification = z.infer<typeof documentProcessingNotification>;
export type NewMentionNotification = z.infer<typeof newMentionNotification>;

View file

@ -0,0 +1,18 @@
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,
});
}

View file

@ -0,0 +1,134 @@
import {
type CreateCommentRequest,
type CreateReplyRequest,
createCommentRequest,
createCommentResponse,
createReplyRequest,
createReplyResponse,
type DeleteCommentRequest,
deleteCommentRequest,
deleteCommentResponse,
type GetCommentsRequest,
type GetMentionsRequest,
getCommentsRequest,
getCommentsResponse,
getMentionsRequest,
getMentionsResponse,
type UpdateCommentRequest,
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));
}
const queryString = params.toString();
const url = queryString ? `/api/v1/mentions?${queryString}` : "/api/v1/mentions";
return baseApiService.get(url, getMentionsResponse);
};
}
export const chatCommentsApiService = new ChatCommentsApiService();

View file

@ -23,6 +23,7 @@ export interface ThreadRecord {
search_space_id: number;
created_at: string;
updated_at: string;
has_comments?: boolean;
}
export interface MessageRecord {

View file

@ -224,6 +224,19 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
CREATE INDEX IF NOT EXISTS idx_documents_search_space_type ON documents(search_space_id, document_type);
`);
// Create the chat_comment_mentions table schema in PGlite
await db.exec(`
CREATE TABLE IF NOT EXISTS chat_comment_mentions (
id INTEGER PRIMARY KEY,
comment_id INTEGER NOT NULL,
mentioned_user_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_user_id ON chat_comment_mentions(mentioned_user_id);
CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_comment_id ON chat_comment_mentions(comment_id);
`);
const electricUrl = getElectricUrl();
// STEP 4: Create the client wrapper

View file

@ -72,4 +72,7 @@ export const cacheKeys = {
["connectors", "google-drive", connectorId, "folders", parentId] as const,
},
},
comments: {
byMessage: (messageId: number) => ["comments", "message", messageId] as const,
},
};