diff --git a/README.md b/README.md index 4f2ce4332..77c34334d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ # SurfSense Connect any LLM to your internal knowledge sources and chat with it in real time alongside your team. OSS alternative to NotebookLM, Perplexity, and Glean. -SurfSense is a highly customizable AI research agent, connected to external sources such as Search Engines (SearxNG, Tavily, LinkUp), Google Drive, Slack, Linear, Jira, ClickUp, Confluence, BookStack, Gmail, Notion, YouTube, GitHub, Discord, Airtable, Google Calendar, Luma, Circleback, Elasticsearch and more to come. +SurfSense is a highly customizable AI research agent, connected to external sources such as Search Engines (SearxNG, Tavily, LinkUp), Google Drive, Slack, Microsoft Teams, Linear, Jira, ClickUp, Confluence, BookStack, Gmail, Notion, YouTube, GitHub, Discord, Airtable, Google Calendar, Luma, Circleback, Elasticsearch and more to come.
MODSetter%2FSurfSense | Trendshift @@ -97,6 +97,7 @@ Contributors can easily add new tools via the registry pattern: - SearxNG (self-hosted instances) - Google Drive - Slack +- Microsoft Teams - Linear - Jira - ClickUp diff --git a/README.zh-CN.md b/README.zh-CN.md index fe6ec8e30..5eb369287 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -18,7 +18,7 @@ 将任何 LLM 连接到您的内部知识源,并与团队成员实时聊天。NotebookLM、Perplexity 和 Glean 的开源替代方案。 -SurfSense 是一个高度可定制的 AI 研究助手,可以连接外部数据源,如搜索引擎(SearxNG、Tavily、LinkUp)、Google Drive、Slack、Linear、Jira、ClickUp、Confluence、BookStack、Gmail、Notion、YouTube、GitHub、Discord、Airtable、Google Calendar、Luma、Circleback、Elasticsearch 等,未来还会支持更多。 +SurfSense 是一个高度可定制的 AI 研究助手,可以连接外部数据源,如搜索引擎(SearxNG、Tavily、LinkUp)、Google Drive、Slack、Microsoft Teams、Linear、Jira、ClickUp、Confluence、BookStack、Gmail、Notion、YouTube、GitHub、Discord、Airtable、Google Calendar、Luma、Circleback、Elasticsearch 等,未来还会支持更多。
MODSetter%2FSurfSense | Trendshift @@ -105,6 +105,7 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7 - SearxNG(自托管实例) - Google Drive - Slack +- Microsoft Teams - Linear - Jira - ClickUp diff --git a/surfsense_backend/alembic/versions/61_add_chat_visibility_and_created_by.py b/surfsense_backend/alembic/versions/61_add_chat_visibility_and_created_by.py new file mode 100644 index 000000000..8ebb99426 --- /dev/null +++ b/surfsense_backend/alembic/versions/61_add_chat_visibility_and_created_by.py @@ -0,0 +1,109 @@ +"""Add chat visibility and created_by_id columns to new_chat_threads + +This migration adds: +- ChatVisibility enum (PRIVATE, SEARCH_SPACE) +- visibility column to new_chat_threads table (default: PRIVATE) +- created_by_id column to track who created the chat thread + +Revision ID: 61 +Revises: 60 +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "61" +down_revision: str | None = "60" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add visibility and created_by_id columns to new_chat_threads.""" + + # Create the ChatVisibility enum type + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'chatvisibility') THEN + CREATE TYPE chatvisibility AS ENUM ('PRIVATE', 'SEARCH_SPACE'); + END IF; + END$$; + """ + ) + + # Add visibility column with default value PRIVATE + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'new_chat_threads' AND column_name = 'visibility' + ) THEN + ALTER TABLE new_chat_threads + ADD COLUMN visibility chatvisibility NOT NULL DEFAULT 'PRIVATE'; + END IF; + END$$; + """ + ) + + # Create index on visibility column for efficient filtering + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_new_chat_threads_visibility + ON new_chat_threads(visibility); + """ + ) + + # Add created_by_id column (nullable to handle existing records) + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'new_chat_threads' AND column_name = 'created_by_id' + ) THEN + ALTER TABLE new_chat_threads + ADD COLUMN created_by_id UUID REFERENCES "user"(id) ON DELETE SET NULL; + END IF; + END$$; + """ + ) + + # Create index on created_by_id column for efficient filtering + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_new_chat_threads_created_by_id + ON new_chat_threads(created_by_id); + """ + ) + + +def downgrade() -> None: + """Remove visibility and created_by_id columns from new_chat_threads.""" + + # Drop indexes + op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_created_by_id") + op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_visibility") + + # Drop columns + op.execute( + """ + ALTER TABLE new_chat_threads + DROP COLUMN IF EXISTS created_by_id; + """ + ) + op.execute( + """ + ALTER TABLE new_chat_threads + DROP COLUMN IF EXISTS visibility; + """ + ) + + # Drop enum type (only if not used elsewhere) + op.execute("DROP TYPE IF EXISTS chatvisibility") diff --git a/surfsense_backend/alembic/versions/61_add_notifications_table.py b/surfsense_backend/alembic/versions/62_add_notifications_table.py similarity index 95% rename from surfsense_backend/alembic/versions/61_add_notifications_table.py rename to surfsense_backend/alembic/versions/62_add_notifications_table.py index 132261686..5f738b9bf 100644 --- a/surfsense_backend/alembic/versions/61_add_notifications_table.py +++ b/surfsense_backend/alembic/versions/62_add_notifications_table.py @@ -1,7 +1,7 @@ """Add notifications table -Revision ID: 61 -Revises: 60 +Revision ID: 62 +Revises: 61 Note: Electric SQL replication setup (REPLICA IDENTITY FULL and publication) is handled in app/db.py setup_electric_replication() which runs on app startup. @@ -11,8 +11,8 @@ from collections.abc import Sequence from alembic import op # revision identifiers, used by Alembic. -revision: str = "61" -down_revision: str | None = "60" +revision: str = "62" +down_revision: str | None = "61" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 4dbc09cf8..44edaee55 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -326,6 +326,20 @@ class NewChatMessageRole(str, Enum): SYSTEM = "system" +class ChatVisibility(str, Enum): + """ + Visibility/sharing level for chat threads. + + PRIVATE: Only the creator can see/access the chat (default) + SEARCH_SPACE: All members of the search space can see/access the chat + PUBLIC: (Future) Anyone with the link can access the chat + """ + + PRIVATE = "PRIVATE" + SEARCH_SPACE = "SEARCH_SPACE" + # PUBLIC = "PUBLIC" # Reserved for future implementation + + class NewChatThread(BaseModel, TimestampMixin): """ Thread model for the new chat feature using assistant-ui. @@ -345,13 +359,31 @@ class NewChatThread(BaseModel, TimestampMixin): index=True, ) + # Visibility/sharing control + visibility = Column( + SQLAlchemyEnum(ChatVisibility), + nullable=False, + default=ChatVisibility.PRIVATE, + server_default="PRIVATE", + index=True, + ) + # Foreign keys search_space_id = Column( Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False ) + # Track who created this chat thread (for visibility filtering) + created_by_id = Column( + UUID(as_uuid=True), + ForeignKey("user.id", ondelete="SET NULL"), + nullable=True, # Nullable for existing records before migration + index=True, + ) + # Relationships search_space = relationship("SearchSpace", back_populates="new_chat_threads") + created_by = relationship("User", back_populates="new_chat_threads") messages = relationship( "NewChatMessage", back_populates="thread", @@ -857,6 +889,13 @@ if config.AUTH_TYPE == "GOOGLE": passive_deletes=True, ) + # Chat threads created by this user + new_chat_threads = relationship( + "NewChatThread", + back_populates="created_by", + passive_deletes=True, + ) + # Page usage tracking for ETL services pages_limit = Column( Integer, @@ -889,6 +928,13 @@ else: passive_deletes=True, ) + # Chat threads created by this user + new_chat_threads = relationship( + "NewChatThread", + back_populates="created_by", + passive_deletes=True, + ) + # Page usage tracking for ETL services pages_limit = Column( Integer, diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 476ff2935..fb5808307 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -19,12 +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.exc import IntegrityError, OperationalError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload from app.db import ( + ChatVisibility, NewChatMessage, NewChatMessageRole, NewChatThread, @@ -40,6 +42,7 @@ from app.schemas.new_chat import ( NewChatThreadCreate, NewChatThreadRead, NewChatThreadUpdate, + NewChatThreadVisibilityUpdate, NewChatThreadWithMessages, ThreadHistoryLoadResponse, ThreadListItem, @@ -52,6 +55,82 @@ from app.utils.rbac import check_permission router = APIRouter() +async def check_thread_access( + session: AsyncSession, + thread: NewChatThread, + user: User, + require_ownership: bool = False, +) -> bool: + """ + Check if a user has access to a thread based on visibility rules. + + Access is granted if: + - User is the creator of the thread + - Thread visibility is SEARCH_SPACE (any member can access) + - Thread is a legacy thread (created_by_id is NULL) - only if user is search space owner + + Args: + session: Database session + thread: The thread to check access for + user: The user requesting access + require_ownership: If True, only the creator can access (for edit/delete operations) + For SEARCH_SPACE threads, any member with permission can access + Legacy threads (NULL creator) are accessible by search space owner + + Returns: + True if access is granted + + Raises: + HTTPException: If access is denied + """ + is_owner = thread.created_by_id == user.id + is_legacy = thread.created_by_id is None + + # Shared threads (SEARCH_SPACE) are accessible by any member + # This check comes first so shared threads are always accessible + if thread.visibility == ChatVisibility.SEARCH_SPACE: + # For ownership-required operations on shared threads, any member can proceed + # (permission check is done at route level) + return True + + # For legacy threads (created before visibility feature), + # only the search space owner can access + if is_legacy: + search_space_query = select(SearchSpace).filter( + SearchSpace.id == thread.search_space_id + ) + search_space_result = await session.execute(search_space_query) + search_space = search_space_result.scalar_one_or_none() + is_search_space_owner = search_space and search_space.user_id == user.id + + if is_search_space_owner: + return True + # Legacy threads are not accessible to non-owners + raise HTTPException( + status_code=403, + detail="You don't have access to this chat", + ) + + # If ownership is required, only the creator can access + if require_ownership: + if not is_owner: + raise HTTPException( + status_code=403, + detail="Only the creator of this chat can perform this action", + ) + return True + + # For read access: owner can access their own private threads + if is_owner: + return True + + # Private thread and user is not the owner + raise HTTPException( + status_code=403, + detail="You don't have access to this private chat", + ) + + # ============================================================================= # Thread Endpoints # ============================================================================= @@ -65,9 +144,14 @@ async def list_threads( user: User = Depends(current_active_user), ): """ - List all threads for the current user in a search space. + List all accessible threads for the current user in a search space. Returns threads and archived_threads for ThreadListPrimitive. + A user can see threads that are: + - Created by them (regardless of visibility) + - Shared with the search space (visibility = SEARCH_SPACE) + - Legacy threads with no creator (created_by_id is NULL) - only if user is search space owner + Args: search_space_id: The search space to list threads for limit: Optional limit on number of threads to return (applies to active threads only) @@ -83,10 +167,33 @@ async def list_threads( "You don't have permission to read chats in this search space", ) - # Get all threads in this search space + # Check if user is the search space owner (for legacy thread visibility) + search_space_query = select(SearchSpace).filter( + SearchSpace.id == search_space_id + ) + search_space_result = await session.execute(search_space_query) + search_space = search_space_result.scalar_one_or_none() + is_search_space_owner = search_space and search_space.user_id == user.id + + # Build filter conditions: + # 1. Created by the current user (any visibility) + # 2. Shared with the search space (visibility = SEARCH_SPACE) + # 3. Legacy threads (created_by_id is NULL) - only visible to search space owner + filter_conditions = [ + NewChatThread.created_by_id == user.id, + NewChatThread.visibility == ChatVisibility.SEARCH_SPACE, + ] + + # Only include legacy threads for the search space owner + if is_search_space_owner: + filter_conditions.append(NewChatThread.created_by_id.is_(None)) + query = ( select(NewChatThread) - .filter(NewChatThread.search_space_id == search_space_id) + .filter( + NewChatThread.search_space_id == search_space_id, + or_(*filter_conditions), + ) .order_by(NewChatThread.updated_at.desc()) ) @@ -98,10 +205,17 @@ async def list_threads( archived_threads = [] for thread in all_threads: + # Legacy threads (no creator) are treated as own threads for owner + is_own_thread = thread.created_by_id == user.id or ( + thread.created_by_id is None and is_search_space_owner + ) item = ThreadListItem( id=thread.id, title=thread.title, archived=thread.archived, + visibility=thread.visibility, + created_by_id=thread.created_by_id, + is_own_thread=is_own_thread, created_at=thread.created_at, updated_at=thread.updated_at, ) @@ -137,7 +251,12 @@ async def search_threads( user: User = Depends(current_active_user), ): """ - Search threads by title in a search space. + Search accessible threads by title in a search space. + + A user can search threads that are: + - Created by them (regardless of visibility) + - Shared with the search space (visibility = SEARCH_SPACE) + - Legacy threads with no creator (created_by_id is NULL) - only if user is search space owner Args: search_space_id: The search space to search in @@ -154,12 +273,31 @@ async def search_threads( "You don't have permission to read chats in this search space", ) - # Search threads by title (case-insensitive) + # Check if user is the search space owner (for legacy thread visibility) + search_space_query = select(SearchSpace).filter( + SearchSpace.id == search_space_id + ) + search_space_result = await session.execute(search_space_query) + search_space = search_space_result.scalar_one_or_none() + is_search_space_owner = search_space and search_space.user_id == user.id + + # Build filter conditions + filter_conditions = [ + NewChatThread.created_by_id == user.id, + NewChatThread.visibility == ChatVisibility.SEARCH_SPACE, + ] + + # Only include legacy threads for the search space owner + if is_search_space_owner: + filter_conditions.append(NewChatThread.created_by_id.is_(None)) + + # Search accessible threads by title (case-insensitive) query = ( select(NewChatThread) .filter( NewChatThread.search_space_id == search_space_id, NewChatThread.title.ilike(f"%{title}%"), + or_(*filter_conditions), ) .order_by(NewChatThread.updated_at.desc()) ) @@ -172,6 +310,13 @@ async def search_threads( id=thread.id, title=thread.title, archived=thread.archived, + visibility=thread.visibility, + created_by_id=thread.created_by_id, + # Legacy threads (no creator) are treated as own threads for owner + is_own_thread=( + thread.created_by_id == user.id + or (thread.created_by_id is None and is_search_space_owner) + ), created_at=thread.created_at, updated_at=thread.updated_at, ) @@ -200,6 +345,9 @@ async def create_thread( """ Create a new chat thread. + The thread is created with the specified visibility (defaults to PRIVATE). + The current user is recorded as the creator of the thread. + Requires CHATS_CREATE permission. """ try: @@ -215,7 +363,9 @@ async def create_thread( db_thread = NewChatThread( title=thread.title, archived=thread.archived, + visibility=thread.visibility, search_space_id=thread.search_space_id, + created_by_id=user.id, updated_at=now, ) session.add(db_thread) @@ -254,6 +404,10 @@ async def get_thread_messages( Get a thread with all its messages. This is used by ThreadHistoryAdapter.load() to restore conversation. + Access is granted if: + - User is the creator of the thread + - Thread visibility is SEARCH_SPACE + Requires CHATS_READ permission. """ try: @@ -268,7 +422,7 @@ async def get_thread_messages( if not thread: raise HTTPException(status_code=404, detail="Thread not found") - # Check permission and ownership + # Check permission to read chats in this search space await check_permission( session, user, @@ -277,6 +431,9 @@ async def get_thread_messages( "You don't have permission to read chats in this search space", ) + # Check thread-level access based on visibility + await check_thread_access(session, thread, user) + # Return messages in the format expected by assistant-ui messages = [ NewChatMessageRead( @@ -313,6 +470,10 @@ async def get_thread_full( """ Get full thread details with all messages. + Access is granted if: + - User is the creator of the thread + - Thread visibility is SEARCH_SPACE + Requires CHATS_READ permission. """ try: @@ -334,6 +495,9 @@ async def get_thread_full( "You don't have permission to read chats in this search space", ) + # Check thread-level access based on visibility + await check_thread_access(session, thread, user) + return thread except HTTPException: @@ -360,6 +524,9 @@ async def update_thread( Update a thread (title, archived status). Used for renaming and archiving threads. + - PRIVATE threads: Only the creator can update + - SEARCH_SPACE threads: Any member with CHATS_UPDATE permission can update + Requires CHATS_UPDATE permission. """ try: @@ -379,6 +546,11 @@ async def update_thread( "You don't have permission to update chats in this search space", ) + # For PRIVATE threads, only the creator can update + # For SEARCH_SPACE threads, any member with permission can update + if db_thread.visibility == ChatVisibility.PRIVATE: + await check_thread_access(session, db_thread, user, require_ownership=True) + # Update fields update_data = thread_update.model_dump(exclude_unset=True) for key, value in update_data.items(): @@ -420,6 +592,9 @@ async def delete_thread( """ Delete a thread and all its messages. + - PRIVATE threads: Only the creator can delete + - SEARCH_SPACE threads: Any member with CHATS_DELETE permission can delete + Requires CHATS_DELETE permission. """ try: @@ -439,6 +614,11 @@ async def delete_thread( "You don't have permission to delete chats in this search space", ) + # For PRIVATE threads, only the creator can delete + # For SEARCH_SPACE threads, any member with permission can delete + if db_thread.visibility == ChatVisibility.PRIVATE: + await check_thread_access(session, db_thread, user, require_ownership=True) + await session.delete(db_thread) await session.commit() return {"message": "Thread deleted successfully"} @@ -463,6 +643,71 @@ async def delete_thread( ) from None +@router.patch("/threads/{thread_id}/visibility", response_model=NewChatThreadRead) +async def update_thread_visibility( + thread_id: int, + visibility_update: NewChatThreadVisibilityUpdate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Update the visibility/sharing settings of a thread. + + Only the creator of the thread can change its visibility. + - PRIVATE: Only the creator can access the thread (default) + - SEARCH_SPACE: All members of the search space can access the thread + + Requires CHATS_UPDATE permission. + """ + try: + result = await session.execute( + select(NewChatThread).filter(NewChatThread.id == thread_id) + ) + db_thread = result.scalars().first() + + if not db_thread: + raise HTTPException(status_code=404, detail="Thread not found") + + await check_permission( + session, + user, + db_thread.search_space_id, + Permission.CHATS_UPDATE.value, + "You don't have permission to update chats in this search space", + ) + + # Only the creator can change visibility + await check_thread_access(session, db_thread, user, require_ownership=True) + + # Update visibility + db_thread.visibility = visibility_update.visibility + db_thread.updated_at = datetime.now(UTC) + + await session.commit() + await session.refresh(db_thread) + return db_thread + + except HTTPException: + raise + except IntegrityError: + await session.rollback() + raise HTTPException( + status_code=400, + detail="Database constraint violation. Please check your input data.", + ) from None + except OperationalError: + await session.rollback() + raise HTTPException( + status_code=503, detail="Database operation failed. Please try again later." + ) from None + except Exception as e: + await session.rollback() + raise HTTPException( + status_code=500, + detail=f"An unexpected error occurred while updating thread visibility: {e!s}", + ) from None + + # ============================================================================= # Message Endpoints # ============================================================================= @@ -479,6 +724,10 @@ async def append_message( Append a message to a thread. This is used by ThreadHistoryAdapter.append() to persist messages. + Access is granted if: + - User is the creator of the thread + - Thread visibility is SEARCH_SPACE + Requires CHATS_UPDATE permission. """ try: @@ -513,6 +762,9 @@ async def append_message( "You don't have permission to update chats in this search space", ) + # Check thread-level access based on visibility + await check_thread_access(session, thread, user) + # Convert string role to enum role_str = ( message.role.lower() if isinstance(message.role, str) else message.role @@ -597,6 +849,10 @@ async def list_messages( """ List messages in a thread with pagination. + Access is granted if: + - User is the creator of the thread + - Thread visibility is SEARCH_SPACE + Requires CHATS_READ permission. """ try: @@ -617,6 +873,9 @@ async def list_messages( "You don't have permission to read chats in this search space", ) + # Check thread-level access based on visibility + await check_thread_access(session, thread, user) + # Get messages query = ( select(NewChatMessage) @@ -659,6 +918,10 @@ async def handle_new_chat( This endpoint handles the new chat functionality with streaming responses using Server-Sent Events (SSE) format compatible with Vercel AI SDK. + Access is granted if: + - User is the creator of the thread + - Thread visibility is SEARCH_SPACE + Requires CHATS_CREATE permission. """ try: @@ -679,6 +942,9 @@ async def handle_new_chat( "You don't have permission to chat in this search space", ) + # Check thread-level access based on visibility + await check_thread_access(session, thread, user) + # Get search space to check LLM config preferences search_space_result = await session.execute( select(SearchSpace).filter(SearchSpace.id == request.search_space_id) @@ -706,6 +972,7 @@ async def handle_new_chat( llm_config_id=llm_config_id, attachments=request.attachments, mentioned_document_ids=request.mentioned_document_ids, + mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids, ), media_type="text/event-stream", headers={ diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py index c5392f284..e90970b29 100644 --- a/surfsense_backend/app/routes/rbac_routes.py +++ b/surfsense_backend/app/routes/rbac_routes.py @@ -556,6 +556,54 @@ async def update_member_role( ) from e +# NOTE: /members/me must be defined BEFORE /members/{membership_id} +# because FastAPI matches routes in order, and "me" would otherwise +# be interpreted as a membership_id (causing a 422 validation error) +@router.delete("/searchspaces/{search_space_id}/members/me") +async def leave_search_space( + search_space_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Leave a search space (remove own membership). + Owners cannot leave their search space. + """ + try: + result = await session.execute( + select(SearchSpaceMembership).filter( + SearchSpaceMembership.user_id == user.id, + SearchSpaceMembership.search_space_id == search_space_id, + ) + ) + db_membership = result.scalars().first() + + if not db_membership: + raise HTTPException( + status_code=404, + detail="You are not a member of this search space", + ) + + if db_membership.is_owner: + raise HTTPException( + status_code=400, + detail="Owners cannot leave their search space. Transfer ownership first or delete the search space.", + ) + + await session.delete(db_membership) + await session.commit() + return {"message": "Successfully left the search space"} + + except HTTPException: + raise + except Exception as e: + await session.rollback() + logger.error(f"Failed to leave search space: {e!s}", exc_info=True) + raise HTTPException( + status_code=500, detail=f"Failed to leave search space: {e!s}" + ) from e + + @router.delete("/searchspaces/{search_space_id}/members/{membership_id}") async def remove_member( search_space_id: int, @@ -608,51 +656,6 @@ async def remove_member( ) from e -@router.delete("/searchspaces/{search_space_id}/members/me") -async def leave_search_space( - search_space_id: int, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - """ - Leave a search space (remove own membership). - Owners cannot leave their search space. - """ - try: - result = await session.execute( - select(SearchSpaceMembership).filter( - SearchSpaceMembership.user_id == user.id, - SearchSpaceMembership.search_space_id == search_space_id, - ) - ) - db_membership = result.scalars().first() - - if not db_membership: - raise HTTPException( - status_code=404, - detail="You are not a member of this search space", - ) - - if db_membership.is_owner: - raise HTTPException( - status_code=400, - detail="Owners cannot leave their search space. Transfer ownership first or delete the search space.", - ) - - await session.delete(db_membership) - await session.commit() - return {"message": "Successfully left the search space"} - - except HTTPException: - raise - except Exception as e: - await session.rollback() - logger.error(f"Failed to leave search space: {e!s}", exc_info=True) - raise HTTPException( - status_code=500, detail=f"Failed to leave search space: {e!s}" - ) from e - - # ============ Invite Endpoints ============ diff --git a/surfsense_backend/app/routes/surfsense_docs_routes.py b/surfsense_backend/app/routes/surfsense_docs_routes.py index a2de65568..e1713e8a3 100644 --- a/surfsense_backend/app/routes/surfsense_docs_routes.py +++ b/surfsense_backend/app/routes/surfsense_docs_routes.py @@ -7,7 +7,7 @@ on a [citation:doc-XXX] link. """ from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -17,8 +17,10 @@ from app.db import ( User, get_async_session, ) +from app.schemas import PaginatedResponse from app.schemas.surfsense_docs import ( SurfsenseDocsChunkRead, + SurfsenseDocsDocumentRead, SurfsenseDocsDocumentWithChunksRead, ) from app.users import current_active_user @@ -87,3 +89,81 @@ async def get_surfsense_doc_by_chunk_id( status_code=500, detail=f"Failed to retrieve Surfsense documentation: {e!s}", ) from e + + +@router.get( + "/surfsense-docs", + response_model=PaginatedResponse[SurfsenseDocsDocumentRead], +) +async def list_surfsense_docs( + page: int = 0, + page_size: int = 50, + title: str | None = None, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + List all Surfsense documentation documents. + + Args: + page: Zero-based page index. + page_size: Number of items per page (default: 50). + title: Optional title filter (case-insensitive substring match). + session: Database session (injected). + user: Current authenticated user (injected). + + Returns: + PaginatedResponse[SurfsenseDocsDocumentRead]: Paginated list of Surfsense docs. + """ + try: + # Base query + query = select(SurfsenseDocsDocument) + count_query = select(func.count()).select_from(SurfsenseDocsDocument) + + # Filter by title if provided + if title and title.strip(): + query = query.filter(SurfsenseDocsDocument.title.ilike(f"%{title}%")) + count_query = count_query.filter( + SurfsenseDocsDocument.title.ilike(f"%{title}%") + ) + + # Get total count + total_result = await session.execute(count_query) + total = total_result.scalar() or 0 + + # Calculate offset + offset = page * page_size + + # Get paginated results + result = await session.execute( + query.order_by(SurfsenseDocsDocument.title).offset(offset).limit(page_size) + ) + docs = result.scalars().all() + + # Convert to response format + items = [ + SurfsenseDocsDocumentRead( + id=doc.id, + title=doc.title, + source=doc.source, + content=doc.content, + created_at=doc.created_at, + updated_at=doc.updated_at, + ) + for doc in docs + ] + + has_more = (offset + len(items)) < total + + return PaginatedResponse( + items=items, + total=total, + page=page, + page_size=page_size, + has_more=has_more, + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to list Surfsense documentation: {e!s}", + ) from e diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index 78498cf04..e6dbcd920 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -8,10 +8,11 @@ These schemas follow the assistant-ui ThreadHistoryAdapter pattern: from datetime import datetime from typing import Any +from uuid import UUID from pydantic import BaseModel, ConfigDict, Field -from app.db import NewChatMessageRole +from app.db import ChatVisibility, NewChatMessageRole from .base import IDModel, TimestampModel @@ -66,6 +67,8 @@ class NewChatThreadCreate(NewChatThreadBase): """Schema for creating a new thread.""" search_space_id: int + # Visibility defaults to PRIVATE, but can be set on creation + visibility: ChatVisibility = ChatVisibility.PRIVATE class NewChatThreadUpdate(BaseModel): @@ -75,12 +78,20 @@ class NewChatThreadUpdate(BaseModel): archived: bool | None = None +class NewChatThreadVisibilityUpdate(BaseModel): + """Schema for updating thread visibility/sharing settings.""" + + visibility: ChatVisibility + + class NewChatThreadRead(NewChatThreadBase, IDModel): """ Schema for reading a thread (matches assistant-ui ThreadRecord). """ search_space_id: int + visibility: ChatVisibility + created_by_id: UUID | None = None created_at: datetime updated_at: datetime @@ -116,6 +127,9 @@ class ThreadListItem(BaseModel): id: int title: str archived: bool + visibility: ChatVisibility + created_by_id: UUID | None = None + is_own_thread: bool = False # True if the current user created this thread created_at: datetime = Field(alias="createdAt") updated_at: datetime = Field(alias="updatedAt") @@ -163,3 +177,6 @@ class NewChatRequest(BaseModel): mentioned_document_ids: list[int] | None = ( None # Optional document IDs mentioned with @ in the chat ) + mentioned_surfsense_doc_ids: list[int] | None = ( + None # Optional SurfSense documentation IDs mentioned with @ in the chat + ) diff --git a/surfsense_backend/app/schemas/surfsense_docs.py b/surfsense_backend/app/schemas/surfsense_docs.py index c6029320f..ce32c0ef8 100644 --- a/surfsense_backend/app/schemas/surfsense_docs.py +++ b/surfsense_backend/app/schemas/surfsense_docs.py @@ -2,6 +2,8 @@ Schemas for Surfsense documentation. """ +from datetime import datetime + from pydantic import BaseModel, ConfigDict @@ -14,6 +16,19 @@ class SurfsenseDocsChunkRead(BaseModel): model_config = ConfigDict(from_attributes=True) +class SurfsenseDocsDocumentRead(BaseModel): + """Schema for a Surfsense docs document (without chunks).""" + + id: int + title: str + source: str + content: str + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = ConfigDict(from_attributes=True) + + class SurfsenseDocsDocumentWithChunksRead(BaseModel): """Schema for a Surfsense docs document with its chunks.""" diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 3b87c33f1..a74f134dc 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -25,7 +25,7 @@ from app.agents.new_chat.llm_config import ( load_agent_config, load_llm_config_from_yaml, ) -from app.db import Document +from app.db import Document, SurfsenseDocsDocument from app.schemas.new_chat import ChatAttachment from app.services.connector_service import ConnectorService from app.services.new_streaming_service import VercelStreamingService @@ -69,6 +69,57 @@ def format_mentioned_documents_as_context(documents: list[Document]) -> str: return "\n".join(context_parts) +def format_mentioned_surfsense_docs_as_context( + documents: list[SurfsenseDocsDocument], +) -> str: + """Format mentioned SurfSense documentation as context for the agent.""" + if not documents: + return "" + + import json + + context_parts = [""] + context_parts.append( + "The user has explicitly mentioned the following SurfSense documentation pages. " + "These are official documentation about how to use SurfSense and should be used to answer questions about the application. " + "Use [citation:CHUNK_ID] format for citations (e.g., [citation:doc-123])." + ) + + for doc in documents: + metadata_json = json.dumps({"source": doc.source}, ensure_ascii=False) + + context_parts.append("") + context_parts.append("") + context_parts.append(f" doc-{doc.id}") + context_parts.append(" SURFSENSE_DOCS") + context_parts.append(f" <![CDATA[{doc.title}]]>") + context_parts.append(f" ") + context_parts.append( + f" " + ) + context_parts.append("") + context_parts.append("") + context_parts.append("") + + if hasattr(doc, "chunks") and doc.chunks: + for chunk in doc.chunks: + context_parts.append( + f" " + ) + else: + context_parts.append( + f" " + ) + + context_parts.append("") + context_parts.append("") + context_parts.append("") + + context_parts.append("") + + return "\n".join(context_parts) + + def extract_todos_from_deepagents(command_output) -> dict: """ Extract todos from deepagents' TodoListMiddleware Command output. @@ -101,6 +152,7 @@ async def stream_new_chat( llm_config_id: int = -1, attachments: list[ChatAttachment] | None = None, mentioned_document_ids: list[int] | None = None, + mentioned_surfsense_doc_ids: list[int] | None = None, ) -> AsyncGenerator[str, None]: """ Stream chat responses from the new SurfSense deep agent. @@ -118,6 +170,7 @@ async def stream_new_chat( messages: Optional chat history from frontend (list of ChatMessage) attachments: Optional attachments with extracted content mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat + mentioned_surfsense_doc_ids: Optional list of SurfSense doc IDs mentioned with @ in the chat Yields: str: SSE formatted response strings @@ -208,7 +261,21 @@ async def stream_new_chat( ) mentioned_documents = list(result.scalars().all()) - # Format the user query with context (attachments + mentioned documents) + # Fetch mentioned SurfSense docs if any + mentioned_surfsense_docs: list[SurfsenseDocsDocument] = [] + if mentioned_surfsense_doc_ids: + from sqlalchemy.orm import selectinload + + result = await session.execute( + select(SurfsenseDocsDocument) + .options(selectinload(SurfsenseDocsDocument.chunks)) + .filter( + SurfsenseDocsDocument.id.in_(mentioned_surfsense_doc_ids), + ) + ) + mentioned_surfsense_docs = list(result.scalars().all()) + + # Format the user query with context (attachments + mentioned documents + surfsense docs) final_query = user_query context_parts = [] @@ -220,6 +287,11 @@ async def stream_new_chat( format_mentioned_documents_as_context(mentioned_documents) ) + if mentioned_surfsense_docs: + context_parts.append( + format_mentioned_surfsense_docs_as_context(mentioned_surfsense_docs) + ) + if context_parts: context = "\n\n".join(context_parts) final_query = f"{context}\n\n{user_query}" @@ -296,13 +368,13 @@ async def stream_new_chat( last_active_step_id = analyze_step_id # Determine step title and action verb based on context - if attachments and mentioned_documents: + if attachments and (mentioned_documents or mentioned_surfsense_docs): last_active_step_title = "Analyzing your content" action_verb = "Reading" elif attachments: last_active_step_title = "Reading your content" action_verb = "Reading" - elif mentioned_documents: + elif mentioned_documents or mentioned_surfsense_docs: last_active_step_title = "Analyzing referenced content" action_verb = "Analyzing" else: @@ -342,6 +414,19 @@ async def stream_new_chat( else: processing_parts.append(f"[{len(doc_names)} documents]") + # Add mentioned SurfSense docs inline + if mentioned_surfsense_docs: + doc_names = [] + for doc in mentioned_surfsense_docs: + title = doc.title + if len(title) > 30: + title = title[:27] + "..." + doc_names.append(title) + if len(doc_names) == 1: + processing_parts.append(f"[📖 {doc_names[0]}]") + else: + processing_parts.append(f"[📖 {len(doc_names)} docs]") + last_active_step_items = [f"{action_verb}: {' '.join(processing_parts)}"] yield streaming_service.format_thinking_step( diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx index 4adb5414c..67413d6f0 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx @@ -47,7 +47,7 @@ export function DocumentsFilters({ columnVisibility, onToggleColumn, }: { - typeCounts: Record; + typeCounts: Partial>; selectedIds: Set; onSearch: (v: string) => void; searchValue: string; diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index 94c0626e6..566e103ac 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -79,17 +79,25 @@ export function DocumentsTableShell({ [documents, sortKey, sortDesc] ); - const allSelectedOnPage = sorted.length > 0 && sorted.every((d) => selectedIds.has(d.id)); - const someSelectedOnPage = sorted.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage; + // Filter out SURFSENSE_DOCS for selection purposes + const selectableDocs = React.useMemo( + () => sorted.filter((d) => d.document_type !== "SURFSENSE_DOCS"), + [sorted] + ); + + const allSelectedOnPage = + selectableDocs.length > 0 && selectableDocs.every((d) => selectedIds.has(d.id)); + const someSelectedOnPage = + selectableDocs.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage; const toggleAll = (checked: boolean) => { const next = new Set(selectedIds); if (checked) - sorted.forEach((d) => { + selectableDocs.forEach((d) => { next.add(d.id); }); else - sorted.forEach((d) => { + selectableDocs.forEach((d) => { next.delete(d.id); }); setSelectedIds(next); @@ -230,9 +238,10 @@ export function DocumentsTableShell({ const icon = getDocumentTypeIcon(doc.document_type); const title = doc.title; const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title; + const isSurfsenseDoc = doc.document_type === "SURFSENSE_DOCS"; return ( toggleOne(doc.id, !!v)} + checked={selectedIds.has(doc.id) && !isSurfsenseDoc} + onCheckedChange={(v) => !isSurfsenseDoc && toggleOne(doc.id, !!v)} + disabled={isSurfsenseDoc} aria-label="Select row" /> diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx index 2fe9ab3e8..d277a84ee 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx @@ -28,6 +28,9 @@ import type { Document } from "./types"; // Only FILE and NOTE document types can be edited const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const; +// SURFSENSE_DOCS are system-managed and cannot be deleted +const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const; + export function RowActions({ document, deleteDocument, @@ -48,6 +51,10 @@ export function RowActions({ document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number] ); + const isDeletable = !NON_DELETABLE_DOCUMENT_TYPES.includes( + document.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number] + ); + const handleDelete = async () => { setIsDeleting(true); try { @@ -120,29 +127,31 @@ export function RowActions({ - - - - - - - -

Delete

-
-
+ + + + +

Delete

+
+ + )}
{/* Mobile Actions Dropdown */} @@ -165,13 +174,15 @@ export function RowActions({ Metadata - setIsDeleteOpen(true)} - className="text-destructive focus:text-destructive" - > - - Delete - + {isDeletable && ( + setIsDeleteOpen(true)} + className="text-destructive focus:text-destructive" + > + + Delete + + )}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx index 6a24e85ad..59593ed8e 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx @@ -2,14 +2,15 @@ import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; -import { RefreshCw } from "lucide-react"; +import { RefreshCw, SquarePlus, Upload } from "lucide-react"; import { motion } from "motion/react"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { toast } from "sonner"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; +import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; import { Button } from "@/components/ui/button"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { documentsApiService } from "@/lib/apis/documents-api.service"; @@ -17,7 +18,7 @@ import { cacheKeys } from "@/lib/query-client/cache-keys"; import { DocumentsFilters } from "./components/DocumentsFilters"; import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell"; import { PaginationControls } from "./components/PaginationControls"; -import type { ColumnVisibility } from "./components/types"; +import type { ColumnVisibility, Document } from "./components/types"; function useDebounced(value: T, delay = 250) { const [debounced, setDebounced] = useState(value); @@ -32,7 +33,13 @@ export default function DocumentsTable() { const t = useTranslations("documents"); const id = useId(); const params = useParams(); + const router = useRouter(); const searchSpaceId = Number(params.search_space_id); + const { openDialog: openUploadDialog } = useDocumentUploadDialog(); + + const handleNewNote = useCallback(() => { + router.push(`/dashboard/${searchSpaceId}/editor/new`); + }, [router, searchSpaceId]); const [search, setSearch] = useState(""); const debouncedSearch = useDebounced(search, 250); @@ -48,33 +55,42 @@ export default function DocumentsTable() { const [sortKey, setSortKey] = useState("title"); const [sortDesc, setSortDesc] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); - const { data: typeCounts } = useAtomValue(documentTypeCountsAtom); + const { data: rawTypeCounts } = useAtomValue(documentTypeCountsAtom); const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); - // Build query parameters for fetching documents + // Filter out SURFSENSE_DOCS from active types for regular documents API + const regularDocumentTypes = useMemo( + () => activeTypes.filter((t) => t !== "SURFSENSE_DOCS"), + [activeTypes] + ); + + // Check if only SURFSENSE_DOCS is selected (skip regular docs query) + const onlySurfsenseDocsSelected = activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS"; + + // Build query parameters for fetching documents (excluding SURFSENSE_DOCS type) const queryParams = useMemo( () => ({ search_space_id: searchSpaceId, page: pageIndex, page_size: pageSize, - ...(activeTypes.length > 0 && { document_types: activeTypes }), + ...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }), }), - [searchSpaceId, pageIndex, pageSize, activeTypes] + [searchSpaceId, pageIndex, pageSize, regularDocumentTypes] ); - // Build search query parameters + // Build search query parameters (excluding SURFSENSE_DOCS type) const searchQueryParams = useMemo( () => ({ search_space_id: searchSpaceId, page: pageIndex, page_size: pageSize, title: debouncedSearch.trim(), - ...(activeTypes.length > 0 && { document_types: activeTypes }), + ...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }), }), - [searchSpaceId, pageIndex, pageSize, activeTypes, debouncedSearch] + [searchSpaceId, pageIndex, pageSize, regularDocumentTypes, debouncedSearch] ); - // Use query for fetching documents + // Use query for fetching documents (disabled when only SURFSENSE_DOCS is selected) const { data: documentsResponse, isLoading: isDocumentsLoading, @@ -84,10 +100,10 @@ export default function DocumentsTable() { queryKey: cacheKeys.documents.globalQueryParams(queryParams), queryFn: () => documentsApiService.getDocuments({ queryParams }), staleTime: 3 * 60 * 1000, // 3 minutes - enabled: !!searchSpaceId && !debouncedSearch.trim(), + enabled: !!searchSpaceId && !debouncedSearch.trim() && !onlySurfsenseDocsSelected, }); - // Use query for searching documents + // Use query for searching documents (disabled when only SURFSENSE_DOCS is selected) const { data: searchResponse, isLoading: isSearchLoading, @@ -97,16 +113,111 @@ export default function DocumentsTable() { queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams), queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }), staleTime: 3 * 60 * 1000, // 3 minutes - enabled: !!searchSpaceId && !!debouncedSearch.trim(), + enabled: !!searchSpaceId && !!debouncedSearch.trim() && !onlySurfsenseDocsSelected, }); + // Determine if we should show SurfSense docs (when no type filter or SURFSENSE_DOCS is selected) + const showSurfsenseDocs = + activeTypes.length === 0 || activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum); + + // Use query for fetching SurfSense docs + const { + data: surfsenseDocsResponse, + isLoading: isSurfsenseDocsLoading, + refetch: refetchSurfsenseDocs, + } = useQuery({ + queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize], + queryFn: () => + documentsApiService.getSurfsenseDocs({ + queryParams: { + page: pageIndex, + page_size: pageSize, + title: debouncedSearch.trim() || undefined, + }, + }), + staleTime: 3 * 60 * 1000, // 3 minutes + enabled: showSurfsenseDocs, + }); + + // Transform SurfSense docs to match the Document type + const surfsenseDocsAsDocuments: Document[] = useMemo(() => { + if (!surfsenseDocsResponse?.items) return []; + return surfsenseDocsResponse.items.map((doc) => ({ + id: doc.id, + title: doc.title, + document_type: "SURFSENSE_DOCS", + document_metadata: { source: doc.source }, + content: doc.content, + created_at: doc.created_at || doc.updated_at || new Date().toISOString(), + search_space_id: -1, // Special value for global docs + })); + }, [surfsenseDocsResponse]); + + // Merge type counts with SURFSENSE_DOCS count + const typeCounts = useMemo(() => { + const counts = { ...(rawTypeCounts || {}) }; + if (surfsenseDocsResponse?.total) { + counts.SURFSENSE_DOCS = surfsenseDocsResponse.total; + } + return counts; + }, [rawTypeCounts, surfsenseDocsResponse?.total]); + // Extract documents and total based on search state - const documents = debouncedSearch.trim() + const regularDocuments = debouncedSearch.trim() ? searchResponse?.items || [] : documentsResponse?.items || []; - const total = debouncedSearch.trim() ? searchResponse?.total || 0 : documentsResponse?.total || 0; - const loading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading; - const error = debouncedSearch.trim() ? searchError : documentsError; + const regularTotal = debouncedSearch.trim() + ? searchResponse?.total || 0 + : documentsResponse?.total || 0; + + // Merge regular documents with SurfSense docs + const documents = useMemo(() => { + // If filtering by type and not including SURFSENSE_DOCS, only show regular docs + if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) { + return regularDocuments; + } + // If filtering only by SURFSENSE_DOCS, only show surfsense docs + if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") { + return surfsenseDocsAsDocuments; + } + // Otherwise, merge both (surfsense docs first) + return [...surfsenseDocsAsDocuments, ...regularDocuments]; + }, [regularDocuments, surfsenseDocsAsDocuments, activeTypes]); + + const total = useMemo(() => { + if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) { + return regularTotal; + } + if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") { + return surfsenseDocsResponse?.total || 0; + } + return regularTotal + (surfsenseDocsResponse?.total || 0); + }, [regularTotal, surfsenseDocsResponse?.total, activeTypes]); + + const loading = useMemo(() => { + // If only SURFSENSE_DOCS selected, only check surfsense loading + if (onlySurfsenseDocsSelected) { + return isSurfsenseDocsLoading; + } + // Otherwise check both regular docs and surfsense docs loading + const regularLoading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading; + return regularLoading || (showSurfsenseDocs && isSurfsenseDocsLoading); + }, [ + onlySurfsenseDocsSelected, + isSurfsenseDocsLoading, + debouncedSearch, + isSearchLoading, + isDocumentsLoading, + showSurfsenseDocs, + ]); + + const error = useMemo(() => { + // If only SURFSENSE_DOCS selected, no regular docs errors + if (onlySurfsenseDocsSelected) { + return null; + } + return debouncedSearch.trim() ? searchError : documentsError; + }, [onlySurfsenseDocsSelected, debouncedSearch, searchError, documentsError]); // Display server-filtered results directly const displayDocs = documents || []; @@ -129,16 +240,33 @@ export default function DocumentsTable() { if (isRefreshing) return; setIsRefreshing(true); try { - if (debouncedSearch.trim()) { - await refetchSearch(); - } else { - await refetchDocuments(); + const refetchPromises: Promise[] = []; + // Only refetch regular documents if not in "only surfsense docs" mode + if (!onlySurfsenseDocsSelected) { + if (debouncedSearch.trim()) { + refetchPromises.push(refetchSearch()); + } else { + refetchPromises.push(refetchDocuments()); + } } + if (showSurfsenseDocs) { + refetchPromises.push(refetchSurfsenseDocs()); + } + await Promise.all(refetchPromises); toast.success(t("refresh_success") || "Documents refreshed"); } finally { setIsRefreshing(false); } - }, [debouncedSearch, refetchSearch, refetchDocuments, t, isRefreshing]); + }, [ + debouncedSearch, + refetchSearch, + refetchDocuments, + refetchSurfsenseDocs, + showSurfsenseDocs, + onlySurfsenseDocsSelected, + t, + isRefreshing, + ]); // Create a delete function for single document deletion const deleteDocument = useCallback( @@ -212,10 +340,20 @@ export default function DocumentsTable() {

{t("title")}

{t("subtitle")}

- +
+ + + +
- {/* Mobile close button */} - + {/* Header with title */} +
+
+ + {/* Mobile close button */} + +
+ {/* Settings Title */} +
+

{t("title")}

+
{/* Navigation Items */} @@ -159,9 +168,11 @@ function SettingsSidebar({ isActive ? "text-foreground" : "text-muted-foreground" )} > - {item.label} + {t(item.labelKey)} +

+

+ {t(item.descriptionKey)}

-

{item.description}

- - {/* Footer */} -
-

Search Space Settings

-
); @@ -194,6 +200,7 @@ function SettingsContent({ searchSpaceId: number; onMenuClick: () => void; }) { + const t = useTranslations("searchSpaceSettings"); const activeItem = settingsNavItems.find((item) => item.id === activeSection); const Icon = activeItem?.icon || Settings; @@ -236,7 +243,7 @@ function SettingsContent({

- {activeItem?.label} + {activeItem ? t(activeItem.labelKey) : ""}

diff --git a/surfsense_web/app/dashboard/user/settings/page.tsx b/surfsense_web/app/dashboard/user/settings/page.tsx index ca89f9ba3..bf88e65e5 100644 --- a/surfsense_web/app/dashboard/user/settings/page.tsx +++ b/surfsense_web/app/dashboard/user/settings/page.tsx @@ -75,20 +75,27 @@ function UserSettingsSidebar({ isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0" )} > -
- - + {/* Header with title */} +
+
+ + +
+ {/* Settings Title */} +
+

{t("title")}

+
- -
-

{t("footer")}

-
); diff --git a/surfsense_web/atoms/chat/mentioned-documents.atom.ts b/surfsense_web/atoms/chat/mentioned-documents.atom.ts index 79ea27d12..aba2736e5 100644 --- a/surfsense_web/atoms/chat/mentioned-documents.atom.ts +++ b/surfsense_web/atoms/chat/mentioned-documents.atom.ts @@ -1,19 +1,25 @@ "use client"; import { atom } from "jotai"; -import type { Document } from "@/contracts/types/document.types"; +import type { Document, SurfsenseDocsDocument } from "@/contracts/types/document.types"; /** * Atom to store the IDs of documents mentioned in the current chat composer. * This is used to pass document context to the backend when sending a message. */ -export const mentionedDocumentIdsAtom = atom([]); +export const mentionedDocumentIdsAtom = atom<{ + surfsense_doc_ids: number[]; + document_ids: number[]; +}>({ + surfsense_doc_ids: [], + document_ids: [], +}); /** * Atom to store the full document objects mentioned in the current chat composer. * This persists across component remounts. */ -export const mentionedDocumentsAtom = atom([]); +export const mentionedDocumentsAtom = atom[]>([]); /** * Simplified document info for display purposes diff --git a/surfsense_web/components/assistant-ui/composer.tsx b/surfsense_web/components/assistant-ui/composer.tsx index 8f8ee5e0b..0e8c5bca5 100644 --- a/surfsense_web/components/assistant-ui/composer.tsx +++ b/surfsense_web/components/assistant-ui/composer.tsx @@ -53,7 +53,14 @@ export const Composer: FC = () => { // Sync mentioned document IDs to atom for use in chat request useEffect(() => { - setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id)); + setMentionedDocumentIds({ + surfsense_doc_ids: mentionedDocuments + .filter((doc) => doc.document_type === "SURFSENSE_DOCS") + .map((doc) => doc.id), + document_ids: mentionedDocuments + .filter((doc) => doc.document_type !== "SURFSENSE_DOCS") + .map((doc) => doc.id), + }); }, [mentionedDocuments, setMentionedDocumentIds]); // Handle text change from inline editor - sync with assistant-ui composer @@ -119,7 +126,10 @@ export const Composer: FC = () => { // Clear the editor after sending editorRef.current?.clear(); setMentionedDocuments([]); - setMentionedDocumentIds([]); + setMentionedDocumentIds({ + surfsense_doc_ids: [], + document_ids: [], + }); } }, [ showDocumentPopover, @@ -129,41 +139,52 @@ export const Composer: FC = () => { setMentionedDocumentIds, ]); - // Handle document removal from inline editor const handleDocumentRemove = useCallback( - (docId: number) => { + (docId: number, docType?: string) => { setMentionedDocuments((prev) => { - const updated = prev.filter((doc) => doc.id !== docId); - // Immediately sync document IDs to avoid race conditions - setMentionedDocumentIds(updated.map((doc) => doc.id)); + const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType)); + setMentionedDocumentIds({ + surfsense_doc_ids: updated + .filter((doc) => doc.document_type === "SURFSENSE_DOCS") + .map((doc) => doc.id), + document_ids: updated + .filter((doc) => doc.document_type !== "SURFSENSE_DOCS") + .map((doc) => doc.id), + }); return updated; }); }, [setMentionedDocuments, setMentionedDocumentIds] ); - // Handle document selection from picker const handleDocumentsMention = useCallback( - (documents: Document[]) => { - // Insert chips into the inline editor for each new document - const existingIds = new Set(mentionedDocuments.map((d) => d.id)); - const newDocs = documents.filter((doc) => !existingIds.has(doc.id)); + (documents: Pick[]) => { + const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)); + const newDocs = documents.filter( + (doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`) + ); for (const doc of newDocs) { editorRef.current?.insertDocumentChip(doc); } - // Update mentioned documents state setMentionedDocuments((prev) => { - const existingIdSet = new Set(prev.map((d) => d.id)); - const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id)); + const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`)); + const uniqueNewDocs = documents.filter( + (doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`) + ); const updated = [...prev, ...uniqueNewDocs]; - // Immediately sync document IDs to avoid race conditions - setMentionedDocumentIds(updated.map((doc) => doc.id)); + setMentionedDocumentIds({ + surfsense_doc_ids: updated + .filter((doc) => doc.document_type === "SURFSENSE_DOCS") + .map((doc) => doc.id), + document_ids: updated + .filter((doc) => doc.document_type !== "SURFSENSE_DOCS") + .map((doc) => doc.id), + }); return updated; }); - // Reset mention query but keep popover open for more selections setMentionQuery(""); }, [mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds] diff --git a/surfsense_web/components/assistant-ui/document-upload-popup.tsx b/surfsense_web/components/assistant-ui/document-upload-popup.tsx index 6ac1ec979..453c6abde 100644 --- a/surfsense_web/components/assistant-ui/document-upload-popup.tsx +++ b/surfsense_web/components/assistant-ui/document-upload-popup.tsx @@ -86,7 +86,6 @@ const DocumentUploadPopupContent: FC<{ }> = ({ isOpen, onOpenChange }) => { const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const router = useRouter(); - const [isAccordionExpanded, setIsAccordionExpanded] = useState(false); if (!searchSpaceId) return null; @@ -118,19 +117,16 @@ const DocumentUploadPopupContent: FC<{ {/* Scrollable Content */}
-
+
- {/* Bottom fade shadow - only show when scrolling */} - {isAccordionExpanded && ( -
- )} + {/* Bottom fade shadow */} +
diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 12a8f895f..f35019216 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -25,7 +25,7 @@ export interface InlineMentionEditorRef { clear: () => void; getText: () => string; getMentionedDocuments: () => MentionedDocument[]; - insertDocumentChip: (doc: Document) => void; + insertDocumentChip: (doc: Pick) => void; } interface InlineMentionEditorProps { @@ -34,7 +34,7 @@ interface InlineMentionEditorProps { onMentionClose?: () => void; onSubmit?: () => void; onChange?: (text: string, docs: MentionedDocument[]) => void; - onDocumentRemove?: (docId: number) => void; + onDocumentRemove?: (docId: number, docType?: string) => void; onKeyDown?: (e: React.KeyboardEvent) => void; disabled?: boolean; className?: string; @@ -44,6 +44,7 @@ interface InlineMentionEditorProps { // Unique data attribute to identify chip elements const CHIP_DATA_ATTR = "data-mention-chip"; const CHIP_ID_ATTR = "data-mention-id"; +const CHIP_DOCTYPE_ATTR = "data-mention-doctype"; /** * Type guard to check if a node is a chip element @@ -66,6 +67,13 @@ function getChipId(element: Element): number | null { return Number.isNaN(id) ? null : id; } +/** + * Get chip document type from element attribute + */ +function getChipDocType(element: Element): string { + return element.getAttribute(CHIP_DOCTYPE_ATTR) ?? "UNKNOWN"; +} + export const InlineMentionEditor = forwardRef( ( { @@ -84,15 +92,17 @@ export const InlineMentionEditor = forwardRef { const editorRef = useRef(null); const [isEmpty, setIsEmpty] = useState(true); - const [mentionedDocs, setMentionedDocs] = useState>( - () => new Map(initialDocuments.map((d) => [d.id, d])) + const [mentionedDocs, setMentionedDocs] = useState>( + () => new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d])) ); const isComposingRef = useRef(false); // Sync initial documents useEffect(() => { if (initialDocuments.length > 0) { - setMentionedDocs(new Map(initialDocuments.map((d) => [d.id, d]))); + setMentionedDocs( + new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d])) + ); } }, [initialDocuments]); @@ -153,6 +163,7 @@ export const InlineMentionEditor = forwardRef { const next = new Map(prev); - next.delete(doc.id); + next.delete(docKey); return next; }); // Notify parent that a document was removed - onDocumentRemove?.(doc.id); + onDocumentRemove?.(doc.id, doc.document_type); focusAtEnd(); }; @@ -195,7 +207,7 @@ export const InlineMentionEditor = forwardRef { + (doc: Pick) => { if (!editorRef.current) return; // Validate required fields for type safety @@ -210,8 +222,9 @@ export const InlineMentionEditor = forwardRef new Map(prev).set(doc.id, mentionDoc)); + // Add to mentioned docs map using unique key + const docKey = `${doc.document_type ?? "UNKNOWN"}:${doc.id}`; + setMentionedDocs((prev) => new Map(prev).set(docKey, mentionDoc)); // Find and remove the @query text const selection = window.getSelection(); @@ -413,15 +426,17 @@ export const InlineMentionEditor = forwardRef { const next = new Map(prev); - next.delete(chipId); + next.delete(chipKey); return next; }); // Notify parent that a document was removed - onDocumentRemove?.(chipId); + onDocumentRemove?.(chipId, chipDocType); } return; } @@ -448,15 +463,17 @@ export const InlineMentionEditor = forwardRef { const next = new Map(prev); - next.delete(chipId); + next.delete(chipKey); return next; }); // Notify parent that a document was removed - onDocumentRemove?.(chipId); + onDocumentRemove?.(chipId, chipDocType); } } } diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx index 5bc905645..5700fbc2e 100644 --- a/surfsense_web/components/assistant-ui/markdown-text.tsx +++ b/surfsense_web/components/assistant-ui/markdown-text.tsx @@ -16,7 +16,8 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button import { cn } from "@/lib/utils"; // Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID] -const CITATION_REGEX = /\[citation:(doc-)?(\d+)\]/g; +// Also matches Chinese brackets 【】 and handles zero-width spaces that LLM sometimes inserts +const CITATION_REGEX = /[[【]\u200B?citation:(doc-)?(\d+)\u200B?[\]】]/g; // Track chunk IDs to citation numbers mapping for consistent numbering // This map is reset when a new message starts rendering @@ -90,10 +91,6 @@ function parseTextWithCitations(text: string): ReactNode[] { } const MarkdownTextImpl = () => { - // Reset citation counter at the start of each render - // This ensures consistent numbering as the message streams in - resetCitationCounter(); - return ( { // Sync mentioned document IDs to atom for use in chat request useEffect(() => { - setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id)); + setMentionedDocumentIds({ + surfsense_doc_ids: mentionedDocuments + .filter((doc) => doc.document_type === "SURFSENSE_DOCS") + .map((doc) => doc.id), + document_ids: mentionedDocuments + .filter((doc) => doc.document_type !== "SURFSENSE_DOCS") + .map((doc) => doc.id), + }); }, [mentionedDocuments, setMentionedDocumentIds]); // Handle text change from inline editor - sync with assistant-ui composer @@ -295,7 +302,10 @@ const Composer: FC = () => { // Clear the editor after sending editorRef.current?.clear(); setMentionedDocuments([]); - setMentionedDocumentIds([]); + setMentionedDocumentIds({ + surfsense_doc_ids: [], + document_ids: [], + }); } }, [ showDocumentPopover, @@ -305,41 +315,52 @@ const Composer: FC = () => { setMentionedDocumentIds, ]); - // Handle document removal from inline editor const handleDocumentRemove = useCallback( - (docId: number) => { + (docId: number, docType?: string) => { setMentionedDocuments((prev) => { - const updated = prev.filter((doc) => doc.id !== docId); - // Immediately sync document IDs to avoid race conditions - setMentionedDocumentIds(updated.map((doc) => doc.id)); + const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType)); + setMentionedDocumentIds({ + surfsense_doc_ids: updated + .filter((doc) => doc.document_type === "SURFSENSE_DOCS") + .map((doc) => doc.id), + document_ids: updated + .filter((doc) => doc.document_type !== "SURFSENSE_DOCS") + .map((doc) => doc.id), + }); return updated; }); }, [setMentionedDocuments, setMentionedDocumentIds] ); - // Handle document selection from picker const handleDocumentsMention = useCallback( - (documents: Document[]) => { - // Insert chips into the inline editor for each new document - const existingIds = new Set(mentionedDocuments.map((d) => d.id)); - const newDocs = documents.filter((doc) => !existingIds.has(doc.id)); + (documents: Pick[]) => { + const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)); + const newDocs = documents.filter( + (doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`) + ); for (const doc of newDocs) { editorRef.current?.insertDocumentChip(doc); } - // Update mentioned documents state setMentionedDocuments((prev) => { - const existingIdSet = new Set(prev.map((d) => d.id)); - const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id)); + const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`)); + const uniqueNewDocs = documents.filter( + (doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`) + ); const updated = [...prev, ...uniqueNewDocs]; - // Immediately sync document IDs to avoid race conditions - setMentionedDocumentIds(updated.map((doc) => doc.id)); + setMentionedDocumentIds({ + surfsense_doc_ids: updated + .filter((doc) => doc.document_type === "SURFSENSE_DOCS") + .map((doc) => doc.id), + document_ids: updated + .filter((doc) => doc.document_type !== "SURFSENSE_DOCS") + .map((doc) => doc.id), + }); return updated; }); - // Reset mention query but keep popover open for more selections setMentionQuery(""); }, [mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds] @@ -640,7 +661,7 @@ const UserMessage: FC = () => { {/* Mentioned documents as chips */} {mentionedDocs?.map((doc) => ( diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index dcf626461..745542304 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -29,7 +29,7 @@ export const UserMessage: FC = () => { {/* Mentioned documents as chips */} {mentionedDocs?.map((doc) => ( diff --git a/surfsense_web/components/layout/index.ts b/surfsense_web/components/layout/index.ts index 18f8cc9d3..67f161d1a 100644 --- a/surfsense_web/components/layout/index.ts +++ b/surfsense_web/components/layout/index.ts @@ -4,14 +4,12 @@ export type { ChatItem, IconRailProps, NavItem, - NoteItem, PageUsage, SearchSpace, SidebarSectionProps, User, } from "./types/layout.types"; export { - AllSearchSpacesSheet, ChatListItem, CreateSearchSpaceDialog, Header, @@ -21,7 +19,6 @@ export { MobileSidebarTrigger, NavIcon, NavSection, - NoteListItem, PageUsageDisplay, SearchSpaceAvatar, Sidebar, diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 70bc96f58..3d4e5630d 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -1,13 +1,12 @@ "use client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useAtomValue, useSetAtom } from "jotai"; -import { Logs, SquareLibrary, Trash2 } from "lucide-react"; +import { useAtomValue } from "jotai"; +import { LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; import { useCallback, useMemo, useState } from "react"; -import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms"; import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; @@ -20,18 +19,15 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { useLogsSummary } from "@/hooks/use-logs"; -import { notesApiService } from "@/lib/apis/notes-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence"; import { resetUser, trackLogout } from "@/lib/posthog/events"; import { cacheKeys } from "@/lib/query-client/cache-keys"; -import type { ChatItem, NavItem, NoteItem, SearchSpace } from "../types/layout.types"; +import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types"; import { CreateSearchSpaceDialog } from "../ui/dialogs"; -import { AllSearchSpacesSheet } from "../ui/sheets"; import { LayoutShell } from "../ui/shell"; -import { AllChatsSidebar } from "../ui/sidebar/AllChatsSidebar"; -import { AllNotesSidebar } from "../ui/sidebar/AllNotesSidebar"; +import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar"; +import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar"; interface LayoutDataProviderProps { searchSpaceId: string; @@ -58,16 +54,11 @@ export function LayoutDataProvider({ const { data: user } = useAtomValue(currentUserAtom); const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom); const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom); - const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom); - const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom); // Current IDs from URL const currentChatId = params?.chat_id ? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id) : null; - const currentNoteId = params?.note_id - ? Number(Array.isArray(params.note_id) ? params.note_id[0] : params.note_id) - : null; // Fetch current search space const { data: searchSpace } = useQuery({ @@ -77,45 +68,17 @@ export function LayoutDataProvider({ }); // Fetch threads - const { data: threadsData, refetch: refetchThreads } = useQuery({ + const { data: threadsData } = useQuery({ queryKey: ["threads", searchSpaceId, { limit: 4 }], queryFn: () => fetchThreads(Number(searchSpaceId), 4), enabled: !!searchSpaceId, }); - // Fetch notes - const { data: notesData, refetch: refetchNotes } = useQuery({ - queryKey: ["notes", searchSpaceId], - queryFn: () => - notesApiService.getNotes({ - search_space_id: Number(searchSpaceId), - page_size: 4, - }), - enabled: !!searchSpaceId, - }); + // Separate sidebar states for shared and private chats + const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false); + const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false); - // Poll for active reindexing tasks to show inline loading indicators - const { summary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, { - enablePolling: true, - refetchInterval: 5000, - }); - - // Create a Set of document IDs that are currently being reindexed - const reindexingDocumentIds = useMemo(() => { - if (!summary?.active_tasks) return new Set(); - return new Set( - summary.active_tasks - .filter((task) => task.document_id != null) - .map((task) => task.document_id as number) - ); - }, [summary?.active_tasks]); - - // All chats/notes sidebars state - const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false); - const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false); - - // Search space sheet and dialog state - const [isAllSearchSpacesSheetOpen, setIsAllSearchSpacesSheetOpen] = useState(false); + // Search space dialog state const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); // Delete dialogs state @@ -123,13 +86,13 @@ export function LayoutDataProvider({ const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [isDeletingChat, setIsDeletingChat] = useState(false); - const [showDeleteNoteDialog, setShowDeleteNoteDialog] = useState(false); - const [noteToDelete, setNoteToDelete] = useState<{ - id: number; - name: string; - search_space_id: number; - } | null>(null); - const [isDeletingNote, setIsDeletingNote] = useState(false); + // Delete/Leave search space dialog state + const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false); + const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false); + const [searchSpaceToDelete, setSearchSpaceToDelete] = useState(null); + const [searchSpaceToLeave, setSearchSpaceToLeave] = useState(null); + const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false); + const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false); const searchSpaces: SearchSpace[] = useMemo(() => { if (!searchSpacesData || !Array.isArray(searchSpacesData)) return []; @@ -149,35 +112,34 @@ export function LayoutDataProvider({ return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null; }, [searchSpaceId, searchSpaces]); - // Transform chats - const chats: ChatItem[] = useMemo(() => { - if (!threadsData?.threads) return []; - return threadsData.threads.map((thread) => ({ - id: thread.id, - name: thread.title || `Chat ${thread.id}`, - url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`, - })); - }, [threadsData, searchSpaceId]); + // Transform and split chats into private and shared based on visibility + const { myChats, sharedChats } = useMemo(() => { + if (!threadsData?.threads) return { myChats: [], sharedChats: [] }; - // Transform notes - const notes: NoteItem[] = useMemo(() => { - if (!notesData?.items) return []; - const sortedNotes = [...notesData.items].sort((a, b) => { - const dateA = a.updated_at - ? new Date(a.updated_at).getTime() - : new Date(a.created_at).getTime(); - const dateB = b.updated_at - ? new Date(b.updated_at).getTime() - : new Date(b.created_at).getTime(); - return dateB - dateA; - }); - return sortedNotes.slice(0, 4).map((note) => ({ - id: note.id, - name: note.title, - url: `/dashboard/${note.search_space_id}/editor/${note.id}`, - isReindexing: reindexingDocumentIds.has(note.id), - })); - }, [notesData, reindexingDocumentIds]); + const privateChats: ChatItem[] = []; + const sharedChatsList: ChatItem[] = []; + + for (const thread of threadsData.threads) { + const chatItem: ChatItem = { + id: thread.id, + name: thread.title || `Chat ${thread.id}`, + url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`, + visibility: thread.visibility, + isOwnThread: thread.is_own_thread, + }; + + // Split based on visibility, not ownership: + // - PRIVATE chats go to "Private Chats" section + // - SEARCH_SPACE chats go to "Shared Chats" section + if (thread.visibility === "SEARCH_SPACE") { + sharedChatsList.push(chatItem); + } else { + privateChats.push(chatItem); + } + } + + return { myChats: privateChats, sharedChats: sharedChatsList }; + }, [threadsData, searchSpaceId]); // Navigation items const navItems: NavItem[] = useMemo( @@ -210,36 +172,80 @@ export function LayoutDataProvider({ setIsCreateSearchSpaceDialogOpen(true); }, []); - const handleSeeAllSearchSpaces = useCallback(() => { - setIsAllSearchSpacesSheetOpen(true); - }, []); - const handleUserSettings = useCallback(() => { router.push("/dashboard/user/settings"); }, [router]); const handleSearchSpaceSettings = useCallback( - (id: number) => { - router.push(`/dashboard/${id}/settings`); + (space: SearchSpace) => { + router.push(`/dashboard/${space.id}/settings`); }, [router] ); - const handleDeleteSearchSpace = useCallback( - async (id: number) => { - await deleteSearchSpace({ id }); + const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => { + // If user is owner, show delete dialog; otherwise show leave dialog + if (space.isOwner) { + setSearchSpaceToDelete(space); + setShowDeleteSearchSpaceDialog(true); + } else { + setSearchSpaceToLeave(space); + setShowLeaveSearchSpaceDialog(true); + } + }, []); + + const confirmDeleteSearchSpace = useCallback(async () => { + if (!searchSpaceToDelete) return; + setIsDeletingSearchSpace(true); + try { + await deleteSearchSpace({ id: searchSpaceToDelete.id }); refetchSearchSpaces(); - if (Number(searchSpaceId) === id && searchSpaces.length > 1) { - const remaining = searchSpaces.filter((s) => s.id !== id); + if (Number(searchSpaceId) === searchSpaceToDelete.id && searchSpaces.length > 1) { + const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToDelete.id); if (remaining.length > 0) { router.push(`/dashboard/${remaining[0].id}/new-chat`); } } else if (searchSpaces.length === 1) { router.push("/dashboard"); } - }, - [deleteSearchSpace, refetchSearchSpaces, searchSpaceId, searchSpaces, router] - ); + } catch (error) { + console.error("Error deleting search space:", error); + } finally { + setIsDeletingSearchSpace(false); + setShowDeleteSearchSpaceDialog(false); + setSearchSpaceToDelete(null); + } + }, [ + searchSpaceToDelete, + deleteSearchSpace, + refetchSearchSpaces, + searchSpaceId, + searchSpaces, + router, + ]); + + const confirmLeaveSearchSpace = useCallback(async () => { + if (!searchSpaceToLeave) return; + setIsLeavingSearchSpace(true); + try { + await searchSpacesApiService.leaveSearchSpace(searchSpaceToLeave.id); + refetchSearchSpaces(); + if (Number(searchSpaceId) === searchSpaceToLeave.id && searchSpaces.length > 1) { + const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToLeave.id); + if (remaining.length > 0) { + router.push(`/dashboard/${remaining[0].id}/new-chat`); + } + } else if (searchSpaces.length === 1) { + router.push("/dashboard"); + } + } catch (error) { + console.error("Error leaving search space:", error); + } finally { + setIsLeavingSearchSpace(false); + setShowLeaveSearchSpaceDialog(false); + setSearchSpaceToLeave(null); + } + }, [searchSpaceToLeave, refetchSearchSpaces, searchSpaceId, searchSpaces, router]); const handleNavItemClick = useCallback( (item: NavItem) => { @@ -264,34 +270,6 @@ export function LayoutDataProvider({ setShowDeleteChatDialog(true); }, []); - const handleNoteSelect = useCallback( - (note: NoteItem) => { - if (hasUnsavedEditorChanges) { - setPendingNavigation(note.url); - } else { - router.push(note.url); - } - }, - [router, hasUnsavedEditorChanges, setPendingNavigation] - ); - - const handleNoteDelete = useCallback( - (note: NoteItem) => { - setNoteToDelete({ id: note.id, name: note.name, search_space_id: Number(searchSpaceId) }); - setShowDeleteNoteDialog(true); - }, - [searchSpaceId] - ); - - const handleAddNote = useCallback(() => { - const newNoteUrl = `/dashboard/${searchSpaceId}/editor/new`; - if (hasUnsavedEditorChanges) { - setPendingNavigation(newNoteUrl); - } else { - router.push(newNoteUrl); - } - }, [router, searchSpaceId, hasUnsavedEditorChanges, setPendingNavigation]); - const handleSettings = useCallback(() => { router.push(`/dashboard/${searchSpaceId}/settings`); }, [router, searchSpaceId]); @@ -318,12 +296,12 @@ export function LayoutDataProvider({ setTheme(theme === "dark" ? "light" : "dark"); }, [theme, setTheme]); - const handleViewAllChats = useCallback(() => { - setIsAllChatsSidebarOpen(true); + const handleViewAllSharedChats = useCallback(() => { + setIsAllSharedChatsSidebarOpen(true); }, []); - const handleViewAllNotes = useCallback(() => { - setIsAllNotesSidebarOpen(true); + const handleViewAllPrivateChats = useCallback(() => { + setIsAllPrivateChatsSidebarOpen(true); }, []); // Delete handlers @@ -345,24 +323,6 @@ export function LayoutDataProvider({ } }, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]); - const confirmDeleteNote = useCallback(async () => { - if (!noteToDelete) return; - setIsDeletingNote(true); - try { - await notesApiService.deleteNote({ - search_space_id: noteToDelete.search_space_id, - note_id: noteToDelete.id, - }); - refetchNotes(); - } catch (error) { - console.error("Error deleting note:", error); - } finally { - setIsDeletingNote(false); - setShowDeleteNoteDialog(false); - setNoteToDelete(null); - } - }, [noteToDelete, refetchNotes]); - // Page usage const pageUsage = user ? { @@ -380,26 +340,23 @@ export function LayoutDataProvider({ searchSpaces={searchSpaces} activeSearchSpaceId={Number(searchSpaceId)} onSearchSpaceSelect={handleSearchSpaceSelect} + onSearchSpaceDelete={handleSearchSpaceDeleteClick} + onSearchSpaceSettings={handleSearchSpaceSettings} onAddSearchSpace={handleAddSearchSpace} searchSpace={activeSearchSpace} navItems={navItems} onNavItemClick={handleNavItemClick} - chats={chats} + chats={myChats} + sharedChats={sharedChats} activeChatId={currentChatId} onNewChat={handleNewChat} onChatSelect={handleChatSelect} onChatDelete={handleChatDelete} - onViewAllChats={handleViewAllChats} - notes={notes} - activeNoteId={currentNoteId} - onNoteSelect={handleNoteSelect} - onNoteDelete={handleNoteDelete} - onAddNote={handleAddNote} - onViewAllNotes={handleViewAllNotes} + onViewAllSharedChats={handleViewAllSharedChats} + onViewAllPrivateChats={handleViewAllPrivateChats} user={{ email: user?.email || "", name: user?.email?.split("@")[0] }} onSettings={handleSettings} onManageMembers={handleManageMembers} - onSeeAllSearchSpaces={handleSeeAllSearchSpaces} onUserSettings={handleUserSettings} onLogout={handleLogout} pageUsage={pageUsage} @@ -455,69 +412,33 @@ export function LayoutDataProvider({ - {/* All Chats Sidebar */} - - - {/* All Notes Sidebar */} - - - {/* All Search Spaces Sheet */} - { - setIsAllSearchSpacesSheetOpen(false); - setIsCreateSearchSpaceDialogOpen(true); - }} - onSettings={handleSearchSpaceSettings} - onDelete={handleDeleteSearchSpace} - /> - - {/* Create Search Space Dialog */} - - - {/* Delete Note Dialog */} - + {/* Delete Search Space Dialog */} + - {t("delete_note")} + {t("delete_search_space")} - {t("delete_note_confirm")} {noteToDelete?.name}?{" "} - {t("action_cannot_undone")} + {t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })} + + {/* Leave Search Space Dialog */} + + + + + + {t("leave_title")} + + + {t("leave_confirm", { name: searchSpaceToLeave?.name || "" })} + + + + + + + + + + {/* All Shared Chats Sidebar */} + + + {/* All Private Chats Sidebar */} + + + {/* Create Search Space Dialog */} + ); } diff --git a/surfsense_web/components/layout/types/layout.types.ts b/surfsense_web/components/layout/types/layout.types.ts index 34598b43e..73ac98fa5 100644 --- a/surfsense_web/components/layout/types/layout.types.ts +++ b/surfsense_web/components/layout/types/layout.types.ts @@ -27,14 +27,8 @@ export interface ChatItem { name: string; url: string; isActive?: boolean; -} - -export interface NoteItem { - id: number; - name: string; - url: string; - isActive?: boolean; - isReindexing?: boolean; + visibility?: "PRIVATE" | "SEARCH_SPACE"; + isOwnThread?: boolean; } export interface PageUsage { @@ -72,17 +66,8 @@ export interface ChatsSectionProps { activeChatId?: number | null; onChatSelect: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; - onViewAllChats?: () => void; - searchSpaceId?: string; -} - -export interface NotesSectionProps { - notes: NoteItem[]; - activeNoteId?: number | null; - onNoteSelect: (note: NoteItem) => void; - onNoteDelete?: (note: NoteItem) => void; - onAddNote?: () => void; - onViewAllNotes?: () => void; + onViewAllSharedChats?: () => void; + onViewAllPrivateChats?: () => void; searchSpaceId?: string; } @@ -107,22 +92,17 @@ export interface SidebarProps { searchSpaceId?: string; navItems: NavItem[]; chats: ChatItem[]; + sharedChats?: ChatItem[]; activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; - onViewAllChats?: () => void; - notes: NoteItem[]; - activeNoteId?: number | null; - onNoteSelect: (note: NoteItem) => void; - onNoteDelete?: (note: NoteItem) => void; - onAddNote?: () => void; - onViewAllNotes?: () => void; + onViewAllSharedChats?: () => void; + onViewAllPrivateChats?: () => void; user: User; theme?: string; onSettings?: () => void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; onToggleTheme?: () => void; onLogout?: () => void; pageUsage?: PageUsage; diff --git a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx index 3e8b14ba9..062e8dcb7 100644 --- a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx +++ b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx @@ -12,6 +12,8 @@ interface IconRailProps { searchSpaces: SearchSpace[]; activeSearchSpaceId: number | null; onSearchSpaceSelect: (id: number) => void; + onSearchSpaceDelete?: (searchSpace: SearchSpace) => void; + onSearchSpaceSettings?: (searchSpace: SearchSpace) => void; onAddSearchSpace: () => void; className?: string; } @@ -20,6 +22,8 @@ export function IconRail({ searchSpaces, activeSearchSpaceId, onSearchSpaceSelect, + onSearchSpaceDelete, + onSearchSpaceSettings, onAddSearchSpace, className, }: IconRailProps) { @@ -32,7 +36,13 @@ export function IconRail({ key={searchSpace.id} name={searchSpace.name} isActive={searchSpace.id === activeSearchSpaceId} + isShared={searchSpace.memberCount > 1} + isOwner={searchSpace.isOwner} onClick={() => onSearchSpaceSelect(searchSpace.id)} + onDelete={onSearchSpaceDelete ? () => onSearchSpaceDelete(searchSpace) : undefined} + onSettings={ + onSearchSpaceSettings ? () => onSearchSpaceSettings(searchSpace) : undefined + } size="md" /> ))} diff --git a/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx index 77f4de899..1786c9c5e 100644 --- a/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx +++ b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx @@ -1,12 +1,25 @@ "use client"; +import { Settings, Trash2, Users } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; interface SearchSpaceAvatarProps { name: string; isActive?: boolean; + isShared?: boolean; + isOwner?: boolean; onClick?: () => void; + onDelete?: () => void; + onSettings?: () => void; size?: "sm" | "md"; } @@ -45,32 +58,103 @@ function getInitials(name: string): string { export function SearchSpaceAvatar({ name, isActive, + isShared, + isOwner = true, onClick, + onDelete, + onSettings, size = "md", }: SearchSpaceAvatarProps) { + const t = useTranslations("searchSpace"); + const tCommon = useTranslations("common"); const bgColor = stringToColor(name); const initials = getInitials(name); const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm"; + const tooltipContent = ( +
+ {name} + {isShared && ( + + {isOwner ? tCommon("owner") : tCommon("shared")} + + )} +
+ ); + + const avatarButton = ( + + ); + + // If delete or settings handlers are provided, wrap with context menu + if (onDelete || onSettings) { + return ( + + + + +
{avatarButton}
+
+
+ + {tooltipContent} + +
+ + {onSettings && ( + + + {tCommon("settings")} + + )} + {onSettings && onDelete && } + {onDelete && isOwner && ( + + + {tCommon("delete")} + + )} + {onDelete && !isOwner && ( + + + {t("leave")} + + )} + +
+ ); + } + + // No context menu needed return ( - - - + {avatarButton} - {name} + {tooltipContent} ); diff --git a/surfsense_web/components/layout/ui/index.ts b/surfsense_web/components/layout/ui/index.ts index bd3d54838..00b862082 100644 --- a/surfsense_web/components/layout/ui/index.ts +++ b/surfsense_web/components/layout/ui/index.ts @@ -1,14 +1,12 @@ export { CreateSearchSpaceDialog } from "./dialogs"; export { Header } from "./header"; export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail"; -export { AllSearchSpacesSheet } from "./sheets"; export { LayoutShell } from "./shell"; export { ChatListItem, MobileSidebar, MobileSidebarTrigger, NavSection, - NoteListItem, PageUsageDisplay, Sidebar, SidebarCollapseButton, diff --git a/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx b/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx deleted file mode 100644 index 401de41c3..000000000 --- a/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx +++ /dev/null @@ -1,241 +0,0 @@ -"use client"; - -import { - Calendar, - MoreHorizontal, - Search, - Settings, - Share2, - Trash2, - UserCheck, - Users, -} from "lucide-react"; -import { useTranslations } from "next-intl"; -import { useState } from "react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; -import type { SearchSpace } from "../../types/layout.types"; - -function formatDate(dateString: string): string { - return new Date(dateString).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); -} - -interface AllSearchSpacesSheetProps { - open: boolean; - onOpenChange: (open: boolean) => void; - searchSpaces: SearchSpace[]; - onSearchSpaceSelect: (id: number) => void; - onCreateNew?: () => void; - onSettings?: (id: number) => void; - onDelete?: (id: number) => void; -} - -export function AllSearchSpacesSheet({ - open, - onOpenChange, - searchSpaces, - onSearchSpaceSelect, - onCreateNew, - onSettings, - onDelete, -}: AllSearchSpacesSheetProps) { - const t = useTranslations("searchSpace"); - const tCommon = useTranslations("common"); - - const [spaceToDelete, setSpaceToDelete] = useState(null); - - const handleSelect = (id: number) => { - onSearchSpaceSelect(id); - onOpenChange(false); - }; - - const handleSettings = (e: React.MouseEvent, space: SearchSpace) => { - e.stopPropagation(); - onOpenChange(false); - onSettings?.(space.id); - }; - - const handleDeleteClick = (e: React.MouseEvent, space: SearchSpace) => { - e.stopPropagation(); - setSpaceToDelete(space); - }; - - const confirmDelete = () => { - if (spaceToDelete) { - onDelete?.(spaceToDelete.id); - setSpaceToDelete(null); - } - }; - - return ( - <> - - - -
-
- -
-
- {t("all_search_spaces")} - - {t("search_spaces_count", { count: searchSpaces.length })} - -
-
-
- -
- {searchSpaces.length === 0 ? ( -
-
- -
-
-

{t("no_search_spaces")}

-

{t("create_first_search_space")}

-
- {onCreateNew && ( - - )} -
- ) : ( - searchSpaces.map((space) => ( - - - - handleSettings(e, space)}> - - {tCommon("settings")} - - - handleDeleteClick(e, space)} - className="text-destructive focus:text-destructive" - > - - {tCommon("delete")} - - - - )} -
-
- -
- - {space.isOwner ? ( - - ) : ( - - )} - {t("members_count", { count: space.memberCount })} - - {space.createdAt && ( - - - {formatDate(space.createdAt)} - - )} -
- - )) - )} -
- - {searchSpaces.length > 0 && onCreateNew && ( -
- -
- )} - - - - !open && setSpaceToDelete(null)}> - - - {t("delete_title")} - - {t("delete_confirm", { name: spaceToDelete?.name ?? "" })} - - - - {tCommon("cancel")} - - {tCommon("delete")} - - - - - - ); -} diff --git a/surfsense_web/components/layout/ui/sheets/index.ts b/surfsense_web/components/layout/ui/sheets/index.ts deleted file mode 100644 index d3db749bb..000000000 --- a/surfsense_web/components/layout/ui/sheets/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AllSearchSpacesSheet } from "./AllSearchSpacesSheet"; diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 1bb0a015a..ed3a09099 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -5,14 +5,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import { useSidebarState } from "../../hooks"; -import type { - ChatItem, - NavItem, - NoteItem, - PageUsage, - SearchSpace, - User, -} from "../../types/layout.types"; +import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; import { Header } from "../header"; import { IconRail } from "../icon-rail"; import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar"; @@ -21,26 +14,23 @@ interface LayoutShellProps { searchSpaces: SearchSpace[]; activeSearchSpaceId: number | null; onSearchSpaceSelect: (id: number) => void; + onSearchSpaceDelete?: (searchSpace: SearchSpace) => void; + onSearchSpaceSettings?: (searchSpace: SearchSpace) => void; onAddSearchSpace: () => void; searchSpace: SearchSpace | null; navItems: NavItem[]; onNavItemClick?: (item: NavItem) => void; chats: ChatItem[]; + sharedChats?: ChatItem[]; activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; - onViewAllChats?: () => void; - notes: NoteItem[]; - activeNoteId?: number | null; - onNoteSelect: (note: NoteItem) => void; - onNoteDelete?: (note: NoteItem) => void; - onAddNote?: () => void; - onViewAllNotes?: () => void; + onViewAllSharedChats?: () => void; + onViewAllPrivateChats?: () => void; user: User; onSettings?: () => void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; onUserSettings?: () => void; onLogout?: () => void; pageUsage?: PageUsage; @@ -58,26 +48,23 @@ export function LayoutShell({ searchSpaces, activeSearchSpaceId, onSearchSpaceSelect, + onSearchSpaceDelete, + onSearchSpaceSettings, onAddSearchSpace, searchSpace, navItems, onNavItemClick, chats, + sharedChats, activeChatId, onNewChat, onChatSelect, onChatDelete, - onViewAllChats, - notes, - activeNoteId, - onNoteSelect, - onNoteDelete, - onAddNote, - onViewAllNotes, + onViewAllSharedChats, + onViewAllPrivateChats, user, onSettings, onManageMembers, - onSeeAllSearchSpaces, onUserSettings, onLogout, pageUsage, @@ -113,26 +100,23 @@ export function LayoutShell({ searchSpaces={searchSpaces} activeSearchSpaceId={activeSearchSpaceId} onSearchSpaceSelect={onSearchSpaceSelect} + onSearchSpaceDelete={onSearchSpaceDelete} + onSearchSpaceSettings={onSearchSpaceSettings} onAddSearchSpace={onAddSearchSpace} searchSpace={searchSpace} navItems={navItems} onNavItemClick={onNavItemClick} chats={chats} + sharedChats={sharedChats} activeChatId={activeChatId} onNewChat={onNewChat} onChatSelect={onChatSelect} onChatDelete={onChatDelete} - onViewAllChats={onViewAllChats} - notes={notes} - activeNoteId={activeNoteId} - onNoteSelect={onNoteSelect} - onNoteDelete={onNoteDelete} - onAddNote={onAddNote} - onViewAllNotes={onViewAllNotes} + onViewAllSharedChats={onViewAllSharedChats} + onViewAllPrivateChats={onViewAllPrivateChats} user={user} onSettings={onSettings} onManageMembers={onManageMembers} - onSeeAllSearchSpaces={onSeeAllSearchSpaces} onUserSettings={onUserSettings} onLogout={onLogout} pageUsage={pageUsage} @@ -155,6 +139,8 @@ export function LayoutShell({ searchSpaces={searchSpaces} activeSearchSpaceId={activeSearchSpaceId} onSearchSpaceSelect={onSearchSpaceSelect} + onSearchSpaceDelete={onSearchSpaceDelete} + onSearchSpaceSettings={onSearchSpaceSettings} onAddSearchSpace={onAddSearchSpace} />
@@ -167,21 +153,16 @@ export function LayoutShell({ navItems={navItems} onNavItemClick={onNavItemClick} chats={chats} + sharedChats={sharedChats} activeChatId={activeChatId} onNewChat={onNewChat} onChatSelect={onChatSelect} onChatDelete={onChatDelete} - onViewAllChats={onViewAllChats} - notes={notes} - activeNoteId={activeNoteId} - onNoteSelect={onNoteSelect} - onNoteDelete={onNoteDelete} - onAddNote={onAddNote} - onViewAllNotes={onViewAllNotes} + onViewAllSharedChats={onViewAllSharedChats} + onViewAllPrivateChats={onViewAllPrivateChats} user={user} onSettings={onSettings} onManageMembers={onManageMembers} - onSeeAllSearchSpaces={onSeeAllSearchSpaces} onUserSettings={onUserSettings} onLogout={onLogout} pageUsage={pageUsage} diff --git a/surfsense_web/components/layout/ui/sidebar/AllNotesSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllNotesSidebar.tsx deleted file mode 100644 index 67d1b4ba6..000000000 --- a/surfsense_web/components/layout/ui/sidebar/AllNotesSidebar.tsx +++ /dev/null @@ -1,407 +0,0 @@ -"use client"; - -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { format } from "date-fns"; -import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useParams, useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { createPortal } from "react-dom"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useDebouncedValue } from "@/hooks/use-debounced-value"; -import { documentsApiService } from "@/lib/apis/documents-api.service"; -import { notesApiService } from "@/lib/apis/notes-api.service"; -import { cn } from "@/lib/utils"; - -interface AllNotesSidebarProps { - open: boolean; - onOpenChange: (open: boolean) => void; - searchSpaceId: string; - onAddNote?: () => void; - onCloseMobileSidebar?: () => void; -} - -export function AllNotesSidebar({ - open, - onOpenChange, - searchSpaceId, - onAddNote, - onCloseMobileSidebar, -}: AllNotesSidebarProps) { - const t = useTranslations("sidebar"); - const router = useRouter(); - const params = useParams(); - const queryClient = useQueryClient(); - - // Get the current note ID from URL to highlight the open note - const currentNoteId = params.note_id ? Number(params.note_id) : null; - const [deletingNoteId, setDeletingNoteId] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const [mounted, setMounted] = useState(false); - const [openDropdownId, setOpenDropdownId] = useState(null); - const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); - - // Handle mounting for portal - useEffect(() => { - setMounted(true); - }, []); - - // Handle escape key - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape" && open) { - onOpenChange(false); - } - }; - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - }, [open, onOpenChange]); - - // Lock body scroll when open - useEffect(() => { - if (open) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = ""; - } - return () => { - document.body.style.overflow = ""; - }; - }, [open]); - - // Fetch all notes (when no search query) - const { - data: notesData, - error: notesError, - isLoading: isLoadingNotes, - } = useQuery({ - queryKey: ["all-notes", searchSpaceId], - queryFn: () => - notesApiService.getNotes({ - search_space_id: Number(searchSpaceId), - page_size: 1000, - }), - enabled: !!searchSpaceId && open && !debouncedSearchQuery, - }); - - // Search notes (when there's a search query) - const { - data: searchData, - error: searchError, - isLoading: isSearching, - } = useQuery({ - queryKey: ["search-notes", searchSpaceId, debouncedSearchQuery], - queryFn: () => - documentsApiService.searchDocuments({ - queryParams: { - search_space_id: Number(searchSpaceId), - document_types: ["NOTE"], - title: debouncedSearchQuery, - page_size: 100, - }, - }), - enabled: !!searchSpaceId && open && !!debouncedSearchQuery, - }); - - // Handle note navigation - const handleNoteClick = useCallback( - (noteId: number, noteSearchSpaceId: number) => { - router.push(`/dashboard/${noteSearchSpaceId}/editor/${noteId}`); - onOpenChange(false); - // Also close the main sidebar on mobile - onCloseMobileSidebar?.(); - }, - [router, onOpenChange, onCloseMobileSidebar] - ); - - // Handle note deletion - const handleDeleteNote = useCallback( - async (noteId: number, noteSearchSpaceId: number) => { - setDeletingNoteId(noteId); - try { - await notesApiService.deleteNote({ - search_space_id: noteSearchSpaceId, - note_id: noteId, - }); - queryClient.invalidateQueries({ queryKey: ["all-notes", searchSpaceId] }); - queryClient.invalidateQueries({ queryKey: ["notes", searchSpaceId] }); - queryClient.invalidateQueries({ queryKey: ["search-notes", searchSpaceId] }); - } catch (error) { - console.error("Error deleting note:", error); - } finally { - setDeletingNoteId(null); - } - }, - [queryClient, searchSpaceId] - ); - - // Clear search - const handleClearSearch = useCallback(() => { - setSearchQuery(""); - }, []); - - // Determine which data to show - const isSearchMode = !!debouncedSearchQuery; - const isLoading = isSearchMode ? isSearching : isLoadingNotes; - const error = isSearchMode ? searchError : notesError; - - // Transform and sort notes data - handle both regular notes and search results - const notes = useMemo(() => { - let notesList: { - id: number; - title: string; - search_space_id: number; - created_at: string; - updated_at?: string | null; - }[]; - - if (isSearchMode && searchData?.items) { - notesList = searchData.items.map((doc) => ({ - id: doc.id, - title: doc.title, - search_space_id: doc.search_space_id, - created_at: doc.created_at, - updated_at: doc.updated_at, - })); - } else { - notesList = notesData?.items ?? []; - } - - // Sort notes by updated_at (most recent first), fallback to created_at - return [...notesList].sort((a, b) => { - const dateA = a.updated_at - ? new Date(a.updated_at).getTime() - : new Date(a.created_at).getTime(); - const dateB = b.updated_at - ? new Date(b.updated_at).getTime() - : new Date(b.created_at).getTime(); - return dateB - dateA; // Descending order (most recent first) - }); - }, [isSearchMode, searchData, notesData]); - - if (!mounted) return null; - - return createPortal( - - {open && ( - <> - {/* Backdrop */} - onOpenChange(false)} - aria-hidden="true" - /> - - {/* Panel */} - - {/* Header */} -
-
-

{t("all_notes") || "All Notes"}

- -
- - {/* Search Input */} -
- - setSearchQuery(e.target.value)} - className="pl-9 pr-8 h-9" - /> - {searchQuery && ( - - )} -
-
- - {/* Scrollable Content */} -
- {isLoading ? ( -
- -
- ) : error ? ( -
- {t("error_loading_notes") || "Error loading notes"} -
- ) : notes.length > 0 ? ( -
- {notes.map((note) => { - const isDeleting = deletingNoteId === note.id; - const isActive = currentNoteId === note.id; - - return ( -
- {/* Main clickable area for navigation */} - - - - - -
-

- {t("created") || "Created"}:{" "} - {format(new Date(note.created_at), "MMM d, yyyy 'at' h:mm a")} -

- {note.updated_at && ( -

- {t("updated") || "Updated"}:{" "} - {format(new Date(note.updated_at), "MMM d, yyyy 'at' h:mm a")} -

- )} -
-
-
- - {/* Actions dropdown - separate from main click area */} - setOpenDropdownId(isOpen ? note.id : null)} - > - - - - - handleDeleteNote(note.id, note.search_space_id)} - className="text-destructive focus:text-destructive" - > - - {t("delete") || "Delete"} - - - -
- ); - })} -
- ) : isSearchMode ? ( -
- -

- {t("no_results_found") || "No notes found"} -

-

- {t("try_different_search") || "Try a different search term"} -

-
- ) : ( -
- -

- {t("no_notes") || "No notes yet"} -

- {onAddNote && ( - - )} -
- )} -
- - {/* Footer with Add Note button */} - {onAddNote && notes.length > 0 && ( -
- -
- )} -
- - )} -
, - document.body - ); -} diff --git a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx similarity index 86% rename from surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx rename to surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx index 02459f2b9..117f8acef 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx @@ -5,6 +5,7 @@ import { format } from "date-fns"; import { ArchiveIcon, Loader2, + Lock, MessageCircleMore, MoreHorizontal, RotateCcwIcon, @@ -15,7 +16,7 @@ import { import { AnimatePresence, motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -38,25 +39,24 @@ import { } from "@/lib/chat/thread-persistence"; import { cn } from "@/lib/utils"; -interface AllChatsSidebarProps { +interface AllPrivateChatsSidebarProps { open: boolean; onOpenChange: (open: boolean) => void; searchSpaceId: string; onCloseMobileSidebar?: () => void; } -export function AllChatsSidebar({ +export function AllPrivateChatsSidebar({ open, onOpenChange, searchSpaceId, onCloseMobileSidebar, -}: AllChatsSidebarProps) { +}: AllPrivateChatsSidebarProps) { const t = useTranslations("sidebar"); const router = useRouter(); const params = useParams(); const queryClient = useQueryClient(); - // Get the current chat ID from URL to check if user is deleting the currently open chat const currentChatId = Array.isArray(params.chat_id) ? Number(params.chat_id[0]) : params.chat_id @@ -72,12 +72,10 @@ export function AllChatsSidebar({ const isSearchMode = !!debouncedSearchQuery.trim(); - // Handle mounting for portal useEffect(() => { setMounted(true); }, []); - // Handle escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape" && open) { @@ -88,7 +86,6 @@ export function AllChatsSidebar({ return () => document.removeEventListener("keydown", handleEscape); }, [open, onOpenChange]); - // Lock body scroll when open useEffect(() => { if (open) { document.body.style.overflow = "hidden"; @@ -100,7 +97,6 @@ export function AllChatsSidebar({ }; }, [open]); - // Fetch all threads (when not searching) const { data: threadsData, error: threadsError, @@ -111,7 +107,6 @@ export function AllChatsSidebar({ enabled: !!searchSpaceId && open && !isSearchMode, }); - // Search threads (when searching) const { data: searchData, error: searchError, @@ -122,18 +117,41 @@ export function AllChatsSidebar({ enabled: !!searchSpaceId && open && isSearchMode, }); - // Handle thread navigation + // Filter to only private chats (PRIVATE visibility or no visibility set) + const { activeChats, archivedChats } = useMemo(() => { + if (isSearchMode) { + const privateSearchResults = (searchData ?? []).filter( + (thread) => thread.visibility !== "SEARCH_SPACE" + ); + return { + activeChats: privateSearchResults.filter((t) => !t.archived), + archivedChats: privateSearchResults.filter((t) => t.archived), + }; + } + + if (!threadsData) return { activeChats: [], archivedChats: [] }; + + const activePrivate = threadsData.threads.filter( + (thread) => thread.visibility !== "SEARCH_SPACE" + ); + const archivedPrivate = threadsData.archived_threads.filter( + (thread) => thread.visibility !== "SEARCH_SPACE" + ); + + return { activeChats: activePrivate, archivedChats: archivedPrivate }; + }, [threadsData, searchData, isSearchMode]); + + const threads = showArchived ? archivedChats : activeChats; + const handleThreadClick = useCallback( (threadId: number) => { router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`); onOpenChange(false); - // Also close the main sidebar on mobile onCloseMobileSidebar?.(); }, [router, onOpenChange, searchSpaceId, onCloseMobileSidebar] ); - // Handle thread deletion const handleDeleteThread = useCallback( async (threadId: number) => { setDeletingThreadId(threadId); @@ -144,10 +162,8 @@ export function AllChatsSidebar({ queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); - // If the deleted chat is currently open, close sidebar first then redirect if (currentChatId === threadId) { onOpenChange(false); - // Wait for sidebar close animation to complete before navigating setTimeout(() => { router.push(`/dashboard/${searchSpaceId}/new-chat`); }, 250); @@ -162,7 +178,6 @@ export function AllChatsSidebar({ [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange] ); - // Handle thread archive/unarchive const handleToggleArchive = useCallback( async (threadId: number, currentlyArchived: boolean) => { setArchivingThreadId(threadId); @@ -186,25 +201,15 @@ export function AllChatsSidebar({ [queryClient, searchSpaceId, t] ); - // Clear search const handleClearSearch = useCallback(() => { setSearchQuery(""); }, []); - // Determine which data source to use - let threads: ThreadListItem[] = []; - if (isSearchMode) { - threads = searchData ?? []; - } else if (threadsData) { - threads = showArchived ? threadsData.archived_threads : threadsData.threads; - } - const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads; const error = isSearchMode ? searchError : threadsError; - // Get counts for tabs - const activeCount = threadsData?.threads.length ?? 0; - const archivedCount = threadsData?.archived_threads.length ?? 0; + const activeCount = activeChats.length; + const archivedCount = archivedChats.length; if (!mounted) return null; @@ -212,32 +217,32 @@ export function AllChatsSidebar({ {open && ( <> - {/* Backdrop */} onOpenChange(false)} aria-hidden="true" /> - {/* Panel */} - {/* Header */} -
+
-

{t("all_chats") || "All Chats"}

+
+ +

{t("chats") || "Private Chats"}

+
- {/* Search Input */}
- {/* Tab toggle for active/archived (only show when not searching) */} {!isSearchMode && ( -
+
)} - {/* Scrollable Content */}
{isLoading ? (
@@ -332,7 +334,6 @@ export function AllChatsSidebar({ isBusy && "opacity-50 pointer-events-none" )} > - {/* Main clickable area for navigation */} - + handleToggleArchive(thread.id, thread.archived)} disabled={isArchiving} @@ -420,11 +420,11 @@ export function AllChatsSidebar({
) : (
- +

{showArchived ? t("no_archived_chats") || "No archived chats" - : t("no_chats") || "No chats yet"} + : t("no_chats") || "No private chats"}

{!showArchived && (

diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx new file mode 100644 index 000000000..d2e7bf1d1 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx @@ -0,0 +1,443 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { + ArchiveIcon, + Loader2, + MessageCircleMore, + MoreHorizontal, + RotateCcwIcon, + Search, + Trash2, + Users, + X, +} from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useParams, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { + deleteThread, + fetchThreads, + searchThreads, + type ThreadListItem, + updateThread, +} from "@/lib/chat/thread-persistence"; +import { cn } from "@/lib/utils"; + +interface AllSharedChatsSidebarProps { + open: boolean; + onOpenChange: (open: boolean) => void; + searchSpaceId: string; + onCloseMobileSidebar?: () => void; +} + +export function AllSharedChatsSidebar({ + open, + onOpenChange, + searchSpaceId, + onCloseMobileSidebar, +}: AllSharedChatsSidebarProps) { + const t = useTranslations("sidebar"); + const router = useRouter(); + const params = useParams(); + const queryClient = useQueryClient(); + + const currentChatId = Array.isArray(params.chat_id) + ? Number(params.chat_id[0]) + : params.chat_id + ? Number(params.chat_id) + : null; + const [deletingThreadId, setDeletingThreadId] = useState(null); + const [archivingThreadId, setArchivingThreadId] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [showArchived, setShowArchived] = useState(false); + const [mounted, setMounted] = useState(false); + const [openDropdownId, setOpenDropdownId] = useState(null); + const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); + + const isSearchMode = !!debouncedSearchQuery.trim(); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && open) { + onOpenChange(false); + } + }; + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [open, onOpenChange]); + + useEffect(() => { + if (open) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [open]); + + const { + data: threadsData, + error: threadsError, + isLoading: isLoadingThreads, + } = useQuery({ + queryKey: ["all-threads", searchSpaceId], + queryFn: () => fetchThreads(Number(searchSpaceId)), + enabled: !!searchSpaceId && open && !isSearchMode, + }); + + const { + data: searchData, + error: searchError, + isLoading: isLoadingSearch, + } = useQuery({ + queryKey: ["search-threads", searchSpaceId, debouncedSearchQuery], + queryFn: () => searchThreads(Number(searchSpaceId), debouncedSearchQuery.trim()), + enabled: !!searchSpaceId && open && isSearchMode, + }); + + // Filter to only shared chats (SEARCH_SPACE visibility) + const { activeChats, archivedChats } = useMemo(() => { + if (isSearchMode) { + const sharedSearchResults = (searchData ?? []).filter( + (thread) => thread.visibility === "SEARCH_SPACE" + ); + return { + activeChats: sharedSearchResults.filter((t) => !t.archived), + archivedChats: sharedSearchResults.filter((t) => t.archived), + }; + } + + if (!threadsData) return { activeChats: [], archivedChats: [] }; + + const activeShared = threadsData.threads.filter( + (thread) => thread.visibility === "SEARCH_SPACE" + ); + const archivedShared = threadsData.archived_threads.filter( + (thread) => thread.visibility === "SEARCH_SPACE" + ); + + return { activeChats: activeShared, archivedChats: archivedShared }; + }, [threadsData, searchData, isSearchMode]); + + const threads = showArchived ? archivedChats : activeChats; + + const handleThreadClick = useCallback( + (threadId: number) => { + router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`); + onOpenChange(false); + onCloseMobileSidebar?.(); + }, + [router, onOpenChange, searchSpaceId, onCloseMobileSidebar] + ); + + const handleDeleteThread = useCallback( + async (threadId: number) => { + setDeletingThreadId(threadId); + try { + await deleteThread(threadId); + toast.success(t("chat_deleted") || "Chat deleted successfully"); + queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); + + if (currentChatId === threadId) { + onOpenChange(false); + setTimeout(() => { + router.push(`/dashboard/${searchSpaceId}/new-chat`); + }, 250); + } + } catch (error) { + console.error("Error deleting thread:", error); + toast.error(t("error_deleting_chat") || "Failed to delete chat"); + } finally { + setDeletingThreadId(null); + } + }, + [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange] + ); + + const handleToggleArchive = useCallback( + async (threadId: number, currentlyArchived: boolean) => { + setArchivingThreadId(threadId); + try { + await updateThread(threadId, { archived: !currentlyArchived }); + toast.success( + currentlyArchived + ? t("chat_unarchived") || "Chat restored" + : t("chat_archived") || "Chat archived" + ); + queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); + } catch (error) { + console.error("Error archiving thread:", error); + toast.error(t("error_archiving_chat") || "Failed to archive chat"); + } finally { + setArchivingThreadId(null); + } + }, + [queryClient, searchSpaceId, t] + ); + + const handleClearSearch = useCallback(() => { + setSearchQuery(""); + }, []); + + const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads; + const error = isSearchMode ? searchError : threadsError; + + const activeCount = activeChats.length; + const archivedCount = archivedChats.length; + + if (!mounted) return null; + + return createPortal( + + {open && ( + <> + onOpenChange(false)} + aria-hidden="true" + /> + + +

+
+
+ +

{t("shared_chats") || "Shared Chats"}

+
+ +
+ +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-8 h-9" + /> + {searchQuery && ( + + )} +
+
+ + {!isSearchMode && ( +
+ + +
+ )} + +
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
+ {t("error_loading_chats") || "Error loading chats"} +
+ ) : threads.length > 0 ? ( +
+ {threads.map((thread) => { + const isDeleting = deletingThreadId === thread.id; + const isArchiving = archivingThreadId === thread.id; + const isBusy = isDeleting || isArchiving; + const isActive = currentChatId === thread.id; + + return ( +
+ + + + + +

+ {t("updated") || "Updated"}:{" "} + {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")} +

+
+
+ + setOpenDropdownId(isOpen ? thread.id : null)} + > + + + + + handleToggleArchive(thread.id, thread.archived)} + disabled={isArchiving} + > + {thread.archived ? ( + <> + + {t("unarchive") || "Restore"} + + ) : ( + <> + + {t("archive") || "Archive"} + + )} + + + handleDeleteThread(thread.id)} + className="text-destructive focus:text-destructive" + > + + {t("delete") || "Delete"} + + + +
+ ); + })} +
+ ) : isSearchMode ? ( +
+ +

+ {t("no_chats_found") || "No chats found"} +

+

+ {t("try_different_search") || "Try a different search term"} +

+
+ ) : ( +
+ +

+ {showArchived + ? t("no_archived_chats") || "No archived chats" + : t("no_shared_chats") || "No shared chats"} +

+ {!showArchived && ( +

+ Share a chat to collaborate with your team +

+ )} +
+ )} +
+ + + )} + , + document.body + ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index c1874bfd1..57fba60c9 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -1,18 +1,10 @@ "use client"; -import { Menu } from "lucide-react"; +import { Menu, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; -import type { - ChatItem, - NavItem, - NoteItem, - PageUsage, - SearchSpace, - User, -} from "../../types/layout.types"; -import { IconRail } from "../icon-rail"; +import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; +import { SearchSpaceAvatar } from "../icon-rail/SearchSpaceAvatar"; import { Sidebar } from "./Sidebar"; interface MobileSidebarProps { @@ -21,26 +13,23 @@ interface MobileSidebarProps { searchSpaces: SearchSpace[]; activeSearchSpaceId: number | null; onSearchSpaceSelect: (id: number) => void; + onSearchSpaceDelete?: (searchSpace: SearchSpace) => void; + onSearchSpaceSettings?: (searchSpace: SearchSpace) => void; onAddSearchSpace: () => void; searchSpace: SearchSpace | null; navItems: NavItem[]; onNavItemClick?: (item: NavItem) => void; chats: ChatItem[]; + sharedChats?: ChatItem[]; activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; - onViewAllChats?: () => void; - notes: NoteItem[]; - activeNoteId?: number | null; - onNoteSelect: (note: NoteItem) => void; - onNoteDelete?: (note: NoteItem) => void; - onAddNote?: () => void; - onViewAllNotes?: () => void; + onViewAllSharedChats?: () => void; + onViewAllPrivateChats?: () => void; user: User; onSettings?: () => void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; onUserSettings?: () => void; onLogout?: () => void; pageUsage?: PageUsage; @@ -61,26 +50,23 @@ export function MobileSidebar({ searchSpaces, activeSearchSpaceId, onSearchSpaceSelect, + onSearchSpaceDelete, + onSearchSpaceSettings, onAddSearchSpace, searchSpace, navItems, onNavItemClick, chats, + sharedChats, activeChatId, onNewChat, onChatSelect, onChatDelete, - onViewAllChats, - notes, - activeNoteId, - onNoteSelect, - onNoteDelete, - onAddNote, - onViewAllNotes, + onViewAllSharedChats, + onViewAllPrivateChats, user, onSettings, onManageMembers, - onSeeAllSearchSpaces, onUserSettings, onLogout, pageUsage, @@ -99,27 +85,43 @@ export function MobileSidebar({ onOpenChange(false); }; - const handleNoteSelect = (note: NoteItem) => { - onNoteSelect(note); - onOpenChange(false); - }; - return ( - + Navigation -
- - - + {/* Horizontal Search Spaces Rail */} +
+
+ {searchSpaces.map((space) => ( +
+ 1} + isOwner={space.isOwner} + onClick={() => handleSearchSpaceSelect(space.id)} + onDelete={onSearchSpaceDelete ? () => onSearchSpaceDelete(space) : undefined} + onSettings={ + onSearchSpaceSettings ? () => onSearchSpaceSettings(space) : undefined + } + size="md" + /> +
+ ))} + +
+ {/* Sidebar Content */}
{ onNewChat(); @@ -134,17 +137,11 @@ export function MobileSidebar({ }} onChatSelect={handleChatSelect} onChatDelete={onChatDelete} - onViewAllChats={onViewAllChats} - notes={notes} - activeNoteId={activeNoteId} - onNoteSelect={handleNoteSelect} - onNoteDelete={onNoteDelete} - onAddNote={onAddNote} - onViewAllNotes={onViewAllNotes} + onViewAllSharedChats={onViewAllSharedChats} + onViewAllPrivateChats={onViewAllPrivateChats} user={user} onSettings={onSettings} onManageMembers={onManageMembers} - onSeeAllSearchSpaces={onSeeAllSearchSpaces} onUserSettings={onUserSettings} onLogout={onLogout} pageUsage={pageUsage} diff --git a/surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx b/surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx deleted file mode 100644 index 0491ebcca..000000000 --- a/surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client"; - -import { FileText, Loader2, MoreHorizontal } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { cn } from "@/lib/utils"; - -interface NoteListItemProps { - name: string; - isActive?: boolean; - isReindexing?: boolean; - onClick?: () => void; - onDelete?: () => void; -} - -export function NoteListItem({ - name, - isActive, - isReindexing, - onClick, - onDelete, -}: NoteListItemProps) { - const t = useTranslations("sidebar"); - - return ( -
- - - {/* Actions dropdown */} -
- - - - - - { - e.stopPropagation(); - onDelete?.(); - }} - className="text-destructive focus:text-destructive" - > - {t("delete")} - - - -
-
- ); -} diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 0fdec2a03..a23bec11a 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -1,22 +1,14 @@ "use client"; -import { FileText, FolderOpen, MessageSquare, PenSquare, Plus } from "lucide-react"; +import { FolderOpen, MessageSquare, PenSquare } from "lucide-react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import type { - ChatItem, - NavItem, - NoteItem, - PageUsage, - SearchSpace, - User, -} from "../../types/layout.types"; +import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; import { ChatListItem } from "./ChatListItem"; import { NavSection } from "./NavSection"; -import { NoteListItem } from "./NoteListItem"; import { PageUsageDisplay } from "./PageUsageDisplay"; import { SidebarCollapseButton } from "./SidebarCollapseButton"; import { SidebarHeader } from "./SidebarHeader"; @@ -30,21 +22,16 @@ interface SidebarProps { navItems: NavItem[]; onNavItemClick?: (item: NavItem) => void; chats: ChatItem[]; + sharedChats?: ChatItem[]; activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; - onViewAllChats?: () => void; - notes: NoteItem[]; - activeNoteId?: number | null; - onNoteSelect: (note: NoteItem) => void; - onNoteDelete?: (note: NoteItem) => void; - onAddNote?: () => void; - onViewAllNotes?: () => void; + onViewAllSharedChats?: () => void; + onViewAllPrivateChats?: () => void; user: User; onSettings?: () => void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; onUserSettings?: () => void; onLogout?: () => void; pageUsage?: PageUsage; @@ -58,21 +45,16 @@ export function Sidebar({ navItems, onNavItemClick, chats, + sharedChats = [], activeChatId, onNewChat, onChatSelect, onChatDelete, - onViewAllChats, - notes, - activeNoteId, - onNoteSelect, - onNoteDelete, - onAddNote, - onViewAllNotes, + onViewAllSharedChats, + onViewAllPrivateChats, user, onSettings, onManageMembers, - onSeeAllSearchSpaces, onUserSettings, onLogout, pageUsage, @@ -103,7 +85,6 @@ export function Sidebar({ isCollapsed={isCollapsed} onSettings={onSettings} onManageMembers={onManageMembers} - onSeeAllSearchSpaces={onSeeAllSearchSpaces} />
{isCollapsed ? (
- {chats.length > 0 && ( + {(chats.length > 0 || sharedChats.length > 0) && ( - {t("recent_chats")} ({chats.length}) - - - )} - {notes.length > 0 && ( - - - - - - {t("notes")} ({notes.length}) + {t("chats")} ({chats.length + sharedChats.length}) )}
) : (
+ {/* Shared Chats Section */} 0 ? ( + onViewAllSharedChats ? ( - {t("view_all_chats")} + + {t("view_all_shared_chats") || "View all shared chats"} + + + ) : undefined + } + > + {sharedChats.length > 0 ? ( +
+ {sharedChats.map((chat) => ( + onChatSelect(chat)} + onDelete={() => onChatDelete?.(chat)} + /> + ))} +
+ ) : ( +

{t("no_shared_chats")}

+ )} +
+ + {/* Private Chats Section */} + + + + + + {t("view_all_private_chats") || "View all private chats"} + ) : undefined } @@ -216,67 +223,7 @@ export function Sidebar({ ))}
) : ( -

{t("no_recent_chats")}

- )} - - - 0 ? ( - - - - - {t("view_all_notes")} - - ) : undefined - } - persistentAction={ - onAddNote && notes.length > 0 ? ( - - - - - {t("add_note")} - - ) : undefined - } - > - {notes.length > 0 ? ( -
- {notes.map((note) => ( - onNoteSelect(note)} - onDelete={() => onNoteDelete?.(note)} - /> - ))} -
- ) : onAddNote ? ( - - ) : ( -

{t("no_notes")}

+

{t("no_chats")}

)}
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx index 4ed5e9d34..6ca057819 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronsUpDown, LayoutGrid, Settings, Users } from "lucide-react"; +import { ChevronsUpDown, Settings, Users } from "lucide-react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { @@ -18,7 +18,6 @@ interface SidebarHeaderProps { isCollapsed?: boolean; onSettings?: () => void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; className?: string; } @@ -27,7 +26,6 @@ export function SidebarHeader({ isCollapsed, onSettings, onManageMembers, - onSeeAllSearchSpaces, className, }: SidebarHeaderProps) { const t = useTranslations("sidebar"); @@ -59,11 +57,6 @@ export function SidebarHeader({ {t("search_space_settings")} - - - - {t("see_all_search_spaces")} -
diff --git a/surfsense_web/components/layout/ui/sidebar/index.ts b/surfsense_web/components/layout/ui/sidebar/index.ts index d98b45ca5..282e4740b 100644 --- a/surfsense_web/components/layout/ui/sidebar/index.ts +++ b/surfsense_web/components/layout/ui/sidebar/index.ts @@ -1,9 +1,8 @@ -export { AllChatsSidebar } from "./AllChatsSidebar"; -export { AllNotesSidebar } from "./AllNotesSidebar"; +export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar"; +export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar"; export { ChatListItem } from "./ChatListItem"; export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; export { NavSection } from "./NavSection"; -export { NoteListItem } from "./NoteListItem"; export { PageUsageDisplay } from "./PageUsageDisplay"; export { Sidebar } from "./Sidebar"; export { SidebarCollapseButton } from "./SidebarCollapseButton"; diff --git a/surfsense_web/components/markdown-viewer.tsx b/surfsense_web/components/markdown-viewer.tsx index 93e3f26e1..91f97830a 100644 --- a/surfsense_web/components/markdown-viewer.tsx +++ b/surfsense_web/components/markdown-viewer.tsx @@ -10,6 +10,14 @@ interface MarkdownViewerProps { export function MarkdownViewer({ content, className }: MarkdownViewerProps) { const components: StreamdownProps["components"] = { // Define custom components for markdown elements + callout: ({ children, ...props }) => ( +
+ {children} +
+ ), p: ({ children, ...props }) => (

{children} diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx index 34b2cc814..fc01b6dd6 100644 --- a/surfsense_web/components/new-chat/chat-header.tsx +++ b/surfsense_web/components/new-chat/chat-header.tsx @@ -5,14 +5,18 @@ import type { GlobalNewLLMConfig, NewLLMConfigPublic, } from "@/contracts/types/new-llm-config.types"; +import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence"; +import { ChatShareButton } from "./chat-share-button"; import { ModelConfigSidebar } from "./model-config-sidebar"; import { ModelSelector } from "./model-selector"; interface ChatHeaderProps { searchSpaceId: number; + thread?: ThreadRecord | null; + onThreadVisibilityChange?: (visibility: ChatVisibility) => void; } -export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { +export function ChatHeader({ searchSpaceId, thread, onThreadVisibilityChange }: ChatHeaderProps) { const [sidebarOpen, setSidebarOpen] = useState(false); const [selectedConfig, setSelectedConfig] = useState< NewLLMConfigPublic | GlobalNewLLMConfig | null @@ -46,8 +50,9 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { }, []); return ( - <> +

+ - +
); } diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx new file mode 100644 index 000000000..28a149f95 --- /dev/null +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { Globe, Loader2, Lock, Share2, Users } from "lucide-react"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + type ChatVisibility, + type ThreadRecord, + updateThreadVisibility, +} from "@/lib/chat/thread-persistence"; +import { cn } from "@/lib/utils"; + +interface ChatShareButtonProps { + thread: ThreadRecord | null; + onVisibilityChange?: (visibility: ChatVisibility) => void; + className?: string; +} + +const visibilityOptions: { + value: ChatVisibility; + label: string; + description: string; + icon: typeof Lock; +}[] = [ + { + value: "PRIVATE", + label: "Private", + description: "Only you can access this chat", + icon: Lock, + }, + { + value: "SEARCH_SPACE", + label: "Search Space", + description: "All members of this search space can access", + icon: Users, + }, +]; + +export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + + const currentVisibility = thread?.visibility ?? "PRIVATE"; + const isOwnThread = thread?.created_by_id !== null; // If we have the thread, we can modify it + + const handleVisibilityChange = useCallback( + async (newVisibility: ChatVisibility) => { + if (!thread || newVisibility === currentVisibility) { + setOpen(false); + return; + } + + setIsUpdating(true); + try { + await updateThreadVisibility(thread.id, newVisibility); + + // Refetch all thread queries to update sidebar immediately + await queryClient.refetchQueries({ + predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads", + }); + + onVisibilityChange?.(newVisibility); + toast.success( + newVisibility === "SEARCH_SPACE" ? "Chat shared with search space" : "Chat is now private" + ); + setOpen(false); + } catch (error) { + console.error("Failed to update visibility:", error); + toast.error("Failed to update sharing settings"); + } finally { + setIsUpdating(false); + } + }, + [thread, currentVisibility, onVisibilityChange, queryClient] + ); + + // Don't show if no thread (new chat that hasn't been created yet) + if (!thread) { + return null; + } + + const CurrentIcon = currentVisibility === "PRIVATE" ? Lock : Users; + + return ( + + + + + + +
+
+ +
+

Share Chat

+

+ Control who can access this conversation +

+
+
+
+ +
+ {/* Updating overlay */} + {isUpdating && ( +
+
+ + Updating... +
+
+ )} + + {visibilityOptions.map((option) => { + const isSelected = currentVisibility === option.value; + const Icon = option.icon; + + return ( + + ); + })} +
+ + {/* Info footer */} +
+
+ +

+ {currentVisibility === "PRIVATE" + ? "This chat is private. Only you can view and interact with it." + : "This chat is shared. All search space members can view, continue the conversation, and delete it."} +

+
+
+
+
+ ); +} diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index 7a9e7aaa5..e89885b1d 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -25,9 +25,9 @@ export interface DocumentMentionPickerRef { interface DocumentMentionPickerProps { searchSpaceId: number; - onSelectionChange: (documents: Document[]) => void; + onSelectionChange: (documents: Pick[]) => void; onDone: () => void; - initialSelectedDocuments?: Document[]; + initialSelectedDocuments?: Pick[]; externalSearch?: string; } @@ -57,7 +57,9 @@ export const DocumentMentionPicker = forwardRef< const scrollContainerRef = useRef(null); // State for pagination - const [accumulatedDocuments, setAccumulatedDocuments] = useState([]); + const [accumulatedDocuments, setAccumulatedDocuments] = useState< + Pick[] + >([]); const [currentPage, setCurrentPage] = useState(0); const [hasMore, setHasMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); @@ -90,6 +92,17 @@ export const DocumentMentionPicker = forwardRef< }; }, [debouncedSearch, searchSpaceId]); + const surfsenseDocsQueryParams = useMemo(() => { + const params: { page: number; page_size: number; title?: string } = { + page: 0, + page_size: PAGE_SIZE, + }; + if (debouncedSearch.trim()) { + params.title = debouncedSearch; + } + return params; + }, [debouncedSearch]); + // Use query for fetching first page of documents const { data: documents, isLoading: isDocumentsLoading } = useQuery({ queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams), @@ -106,22 +119,45 @@ export const DocumentMentionPicker = forwardRef< enabled: !!searchSpaceId && !!debouncedSearch.trim() && currentPage === 0, }); - // Update accumulated documents when first page loads + // Use query for fetching first page of SurfSense docs + const { data: surfsenseDocs, isLoading: isSurfsenseDocsLoading } = useQuery({ + queryKey: ["surfsense-docs-mention", debouncedSearch], + queryFn: () => documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }), + staleTime: 3 * 60 * 1000, + }); + + // Update accumulated documents when first page loads - combine both sources useEffect(() => { if (currentPage === 0) { + const combinedDocs: Pick[] = []; + + // Add SurfSense docs first (they appear at top) + if (surfsenseDocs?.items) { + for (const doc of surfsenseDocs.items) { + combinedDocs.push({ + id: doc.id, + title: doc.title, + document_type: "SURFSENSE_DOCS", + }); + } + } + + // Add regular documents if (debouncedSearch.trim()) { - if (searchedDocuments) { - setAccumulatedDocuments(searchedDocuments.items); + if (searchedDocuments?.items) { + combinedDocs.push(...searchedDocuments.items); setHasMore(searchedDocuments.has_more); } } else { - if (documents) { - setAccumulatedDocuments(documents.items); + if (documents?.items) { + combinedDocs.push(...documents.items); setHasMore(documents.has_more); } } + + setAccumulatedDocuments(combinedDocs); } - }, [documents, searchedDocuments, debouncedSearch, currentPage]); + }, [documents, searchedDocuments, surfsenseDocs, debouncedSearch, currentPage]); // Function to load next page const loadNextPage = useCallback(async () => { @@ -175,22 +211,24 @@ export const DocumentMentionPicker = forwardRef< const actualDocuments = accumulatedDocuments; const actualLoading = - (debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) && currentPage === 0; + ((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) || + isSurfsenseDocsLoading) && + currentPage === 0; - // Track already selected document IDs - const selectedIds = useMemo( - () => new Set(initialSelectedDocuments.map((d) => d.id)), + // Track already selected documents using unique key (document_type:id) to avoid ID collisions + const selectedKeys = useMemo( + () => new Set(initialSelectedDocuments.map((d) => `${d.document_type}:${d.id}`)), [initialSelectedDocuments] ); // Filter out already selected documents for navigation const selectableDocuments = useMemo( - () => actualDocuments.filter((doc) => !selectedIds.has(doc.id)), - [actualDocuments, selectedIds] + () => actualDocuments.filter((doc) => !selectedKeys.has(`${doc.document_type}:${doc.id}`)), + [actualDocuments, selectedKeys] ); const handleSelectDocument = useCallback( - (doc: Document) => { + (doc: Pick) => { onSelectionChange([...initialSelectedDocuments, doc]); onDone(); }, @@ -287,13 +325,16 @@ export const DocumentMentionPicker = forwardRef< ) : (
{actualDocuments.map((doc) => { - const isAlreadySelected = selectedIds.has(doc.id); - const selectableIndex = selectableDocuments.findIndex((d) => d.id === doc.id); + const docKey = `${doc.document_type}:${doc.id}`; + const isAlreadySelected = selectedKeys.has(docKey); + const selectableIndex = selectableDocuments.findIndex( + (d) => d.document_type === doc.document_type && d.id === doc.id + ); const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; return (