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/app/db.py b/surfsense_backend/app/db.py index a0b174bf6..73727a9ef 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", @@ -826,6 +858,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, @@ -852,6 +891,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..e9b0c569a 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,61 @@ from app.utils.rbac import check_permission router = APIRouter() +async def check_thread_access( + 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 (and user has permission to read chats) + - Thread is a legacy thread (created_by_id is NULL) - visible to all + + Args: + 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) + Legacy threads (NULL creator) are treated as accessible by all + + 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 + + # Legacy threads are accessible to all users in the search space + if is_legacy: + return True + + # 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 or shared threads + if is_owner: + return True + + if thread.visibility == ChatVisibility.SEARCH_SPACE: + 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 +123,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) + 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 +146,20 @@ async def list_threads( "You don't have permission to read chats in this search space", ) - # Get all threads in this search space + # Get threads that are either: + # 1. Created by the current user (any visibility) + # 2. Shared with the search space (visibility = SEARCH_SPACE) + # 3. Legacy threads with no creator (created_by_id is NULL) - visible to all query = ( select(NewChatThread) - .filter(NewChatThread.search_space_id == search_space_id) + .filter( + NewChatThread.search_space_id == search_space_id, + or_( + NewChatThread.created_by_id == user.id, + NewChatThread.visibility == ChatVisibility.SEARCH_SPACE, + NewChatThread.created_by_id.is_(None), # Legacy threads + ), + ) .order_by(NewChatThread.updated_at.desc()) ) @@ -98,10 +171,17 @@ async def list_threads( archived_threads = [] for thread in all_threads: + # Legacy threads (no creator) are treated as own threads for display purposes + is_own_thread = ( + thread.created_by_id == user.id or thread.created_by_id is None + ) 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 +217,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) Args: search_space_id: The search space to search in @@ -154,12 +239,17 @@ async def search_threads( "You don't have permission to read chats in this search space", ) - # Search threads by title (case-insensitive) + # Search accessible threads by title (case-insensitive) query = ( select(NewChatThread) .filter( NewChatThread.search_space_id == search_space_id, NewChatThread.title.ilike(f"%{title}%"), + or_( + NewChatThread.created_by_id == user.id, + NewChatThread.visibility == ChatVisibility.SEARCH_SPACE, + NewChatThread.created_by_id.is_(None), # Legacy threads + ), ) .order_by(NewChatThread.updated_at.desc()) ) @@ -172,6 +262,12 @@ 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 + is_own_thread=( + thread.created_by_id == user.id or thread.created_by_id is None + ), created_at=thread.created_at, updated_at=thread.updated_at, ) @@ -200,6 +296,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 +314,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 +355,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 +373,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 +382,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(thread, user) + # Return messages in the format expected by assistant-ui messages = [ NewChatMessageRead( @@ -313,6 +421,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 +446,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(thread, user) + return thread except HTTPException: @@ -360,6 +475,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 +497,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(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 +543,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 +565,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(db_thread, user, require_ownership=True) + await session.delete(db_thread) await session.commit() return {"message": "Thread deleted successfully"} @@ -463,6 +594,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(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 +675,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 +713,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(thread, user) + # Convert string role to enum role_str = ( message.role.lower() if isinstance(message.role, str) else message.role @@ -597,6 +800,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 +824,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(thread, user) + # Get messages query = ( select(NewChatMessage) @@ -659,6 +869,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 +893,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(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) diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index 78498cf04..79b443529 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") 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 019f5796a..c0cf9a858 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,9 +2,9 @@ import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; -import { RefreshCw } from "lucide-react"; +import { RefreshCw, SquarePlus } 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, useRef, useState } from "react"; import { toast } from "sonner"; @@ -34,8 +34,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 handleNewNote = useCallback(() => { + router.push(`/dashboard/${searchSpaceId}/editor/new`); + }, [router, searchSpaceId]); + const [search, setSearch] = useState(""); const debouncedSearch = useDebounced(search, 250); const [activeTypes, setActiveTypes] = useState([]); @@ -238,10 +243,16 @@ export default function DocumentsTable() {

{t("title")}

{t("subtitle")}

- +
+ + +
diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx index 239fdc5c1..99ccefcd9 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx @@ -267,21 +267,8 @@ export default function EditorPage() { setHasUnsavedChanges(false); toast.success("Note created successfully! Reindexing in background..."); - // Invalidate notes query to refresh the sidebar - queryClient.invalidateQueries({ - queryKey: ["notes", String(searchSpaceId)], - }); - - // Update URL to reflect the new document ID without navigation - window.history.replaceState({}, "", `/dashboard/${searchSpaceId}/editor/${note.id}`); - // Update document state to reflect the new ID - setDocument({ - document_id: note.id, - title: title, - document_type: "NOTE", - blocknote_document: editorContent, - updated_at: new Date().toISOString(), - }); + // Redirect to documents page after successful save + router.push(`/dashboard/${searchSpaceId}/documents`); } else { // Existing document - save normally if (!editorContent) { @@ -310,12 +297,8 @@ export default function EditorPage() { setHasUnsavedChanges(false); toast.success("Document saved! Reindexing in background..."); - // Invalidate notes query when updating notes to refresh the sidebar - if (isNote) { - queryClient.invalidateQueries({ - queryKey: ["notes", String(searchSpaceId)], - }); - } + // Redirect to documents page after successful save + router.push(`/dashboard/${searchSpaceId}/documents`); } } catch (error) { console.error("Error saving document:", error); @@ -336,7 +319,7 @@ export default function EditorPage() { if (hasUnsavedChanges) { setShowUnsavedDialog(true); } else { - router.push(`/dashboard/${searchSpaceId}/new-chat`); + router.push(`/dashboard/${searchSpaceId}/documents`); } }; @@ -346,12 +329,12 @@ export default function EditorPage() { setGlobalHasUnsavedChanges(false); setHasUnsavedChanges(false); - // If there's a pending navigation (from sidebar), use that; otherwise go back to chat + // If there's a pending navigation (from sidebar), use that; otherwise go back to documents if (pendingNavigation) { router.push(pendingNavigation); setPendingNavigation(null); } else { - router.push(`/dashboard/${searchSpaceId}/new-chat`); + router.push(`/dashboard/${searchSpaceId}/documents`); } }; @@ -392,7 +375,7 @@ export default function EditorPage() { - - - - ); } diff --git a/surfsense_web/components/layout/types/layout.types.ts b/surfsense_web/components/layout/types/layout.types.ts index 34598b43e..f8d06ab73 100644 --- a/surfsense_web/components/layout/types/layout.types.ts +++ b/surfsense_web/components/layout/types/layout.types.ts @@ -27,6 +27,8 @@ export interface ChatItem { name: string; url: string; isActive?: boolean; + visibility?: "PRIVATE" | "SEARCH_SPACE"; + isOwnThread?: boolean; } export interface NoteItem { @@ -76,16 +78,6 @@ export interface ChatsSectionProps { searchSpaceId?: string; } -export interface NotesSectionProps { - notes: NoteItem[]; - activeNoteId?: number | null; - onNoteSelect: (note: NoteItem) => void; - onNoteDelete?: (note: NoteItem) => void; - onAddNote?: () => void; - onViewAllNotes?: () => void; - searchSpaceId?: string; -} - export interface PageUsageDisplayProps { pagesUsed: number; pagesLimit: number; @@ -107,17 +99,12 @@ 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; user: User; theme?: string; onSettings?: () => void; diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 1bb0a015a..26ee43541 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"; @@ -26,17 +19,13 @@ interface LayoutShellProps { 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; @@ -63,17 +52,13 @@ export function LayoutShell({ navItems, onNavItemClick, chats, + sharedChats, activeChatId, onNewChat, onChatSelect, onChatDelete, - onViewAllChats, - notes, - activeNoteId, - onNoteSelect, - onNoteDelete, - onAddNote, - onViewAllNotes, + onViewAllSharedChats, + onViewAllPrivateChats, user, onSettings, onManageMembers, @@ -118,17 +103,13 @@ 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} @@ -167,17 +148,13 @@ 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} diff --git a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx index 02459f2b9..4b85808e5 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx @@ -4,18 +4,21 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { format } from "date-fns"; import { ArchiveIcon, + Globe, Loader2, + Lock, 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, 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,6 +41,8 @@ import { } from "@/lib/chat/thread-persistence"; import { cn } from "@/lib/utils"; +type TabType = "shared" | "private"; + interface AllChatsSidebarProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -65,7 +70,7 @@ export function AllChatsSidebar({ const [deletingThreadId, setDeletingThreadId] = useState(null); const [archivingThreadId, setArchivingThreadId] = useState(null); const [searchQuery, setSearchQuery] = useState(""); - const [showArchived, setShowArchived] = useState(false); + const [activeTab, setActiveTab] = useState("shared"); const [mounted, setMounted] = useState(false); const [openDropdownId, setOpenDropdownId] = useState(null); const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); @@ -122,6 +127,34 @@ export function AllChatsSidebar({ enabled: !!searchSpaceId && open && isSearchMode, }); + // Split threads into shared and private based on visibility + const { sharedChats, privateChats } = useMemo(() => { + let allThreads: ThreadListItem[] = []; + + if (isSearchMode) { + allThreads = searchData ?? []; + } else if (threadsData) { + // Combine active and archived threads for filtering + allThreads = [...threadsData.threads, ...threadsData.archived_threads]; + } + + const shared: ThreadListItem[] = []; + const privateChatsList: ThreadListItem[] = []; + + for (const thread of allThreads) { + if (thread.visibility === "SEARCH_SPACE") { + shared.push(thread); + } else { + privateChatsList.push(thread); + } + } + + return { sharedChats: shared, privateChats: privateChatsList }; + }, [threadsData, searchData, isSearchMode]); + + // Get threads for current tab + const threads = activeTab === "shared" ? sharedChats : privateChats; + // Handle thread navigation const handleThreadClick = useCallback( (threadId: number) => { @@ -191,20 +224,12 @@ export function AllChatsSidebar({ 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 sharedCount = sharedChats.length; + const privateCount = privateChats.length; if (!mounted) return null; @@ -218,7 +243,7 @@ export function AllChatsSidebar({ animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} - className="fixed inset-0 z-[70] bg-black/50" + className="fixed inset-0 z-70 bg-black/50" onClick={() => onOpenChange(false)} aria-hidden="true" /> @@ -229,13 +254,13 @@ export function AllChatsSidebar({ animate={{ x: 0 }} exit={{ x: "-100%" }} transition={{ type: "spring", damping: 25, stiffness: 300 }} - className="fixed inset-y-0 left-0 z-[70] w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate" + className="fixed inset-y-0 left-0 z-70 w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate" role="dialog" aria-modal="true" aria-label={t("all_chats") || "All Chats"} > {/* Header */} -
+

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

- {/* Tab toggle for active/archived (only show when not searching) */} - {!isSearchMode && ( -
- - -
- )} + {/* Tab toggle for shared/private chats */} +
+ + +
{/* Scrollable Content */}
@@ -320,6 +345,7 @@ export function AllChatsSidebar({ const isArchiving = archivingThreadId === thread.id; const isBusy = isDeleting || isArchiving; const isActive = currentChatId === thread.id; + const isShared = thread.visibility === "SEARCH_SPACE"; return (
{/* Main clickable area for navigation */} @@ -343,13 +370,21 @@ export function AllChatsSidebar({ > {thread.title || "New Chat"} + {thread.archived && ( + + )} -

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

+
+

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

+ {thread.archived && ( +

Archived

+ )} +
@@ -377,7 +412,7 @@ export function AllChatsSidebar({ {t("more_options") || "More options"} - + handleToggleArchive(thread.id, thread.archived)} disabled={isArchiving} @@ -420,16 +455,26 @@ export function AllChatsSidebar({
) : (
- -

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

- {!showArchived && ( -

- {t("start_new_chat_hint") || "Start a new chat from the chat page"} -

+ {activeTab === "shared" ? ( + <> + +

+ {t("no_shared_chats") || "No shared chats"} +

+

+ Share a chat to collaborate with your team +

+ + ) : ( + <> + +

+ {t("no_chats") || "No private chats"} +

+

+ {t("start_new_chat_hint") || "Start a new chat from the chat page"} +

+ )}
)} diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx new file mode 100644 index 000000000..117f8acef --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx @@ -0,0 +1,443 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { + ArchiveIcon, + Loader2, + Lock, + MessageCircleMore, + MoreHorizontal, + RotateCcwIcon, + 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 { 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 AllPrivateChatsSidebarProps { + open: boolean; + onOpenChange: (open: boolean) => void; + searchSpaceId: string; + onCloseMobileSidebar?: () => void; +} + +export function AllPrivateChatsSidebar({ + open, + onOpenChange, + searchSpaceId, + onCloseMobileSidebar, +}: AllPrivateChatsSidebarProps) { + 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 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); + 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("chats") || "Private 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_chats") || "No private chats"} +

+ {!showArchived && ( +

+ {t("start_new_chat_hint") || "Start a new chat from the chat page"} +

+ )} +
+ )} +
+
+ + )} +
, + document.body + ); +} 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..988798ae7 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 { @@ -26,17 +18,13 @@ interface MobileSidebarProps { 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; @@ -66,17 +54,13 @@ export function MobileSidebar({ navItems, onNavItemClick, chats, + sharedChats, activeChatId, onNewChat, onChatSelect, onChatDelete, - onViewAllChats, - notes, - activeNoteId, - onNoteSelect, - onNoteDelete, - onAddNote, - onViewAllNotes, + onViewAllSharedChats, + onViewAllPrivateChats, user, onSettings, onManageMembers, @@ -99,27 +83,37 @@ export function MobileSidebar({ onOpenChange(false); }; - const handleNoteSelect = (note: NoteItem) => { - onNoteSelect(note); - onOpenChange(false); - }; - return ( - + Navigation -
- - - + {/* Horizontal Search Spaces Rail */} +
+
+ {searchSpaces.map((space) => ( +
+ handleSearchSpaceSelect(space.id)} + size="md" + /> +
+ ))} + +
+ {/* Sidebar Content */}
{ onNewChat(); @@ -134,13 +129,8 @@ 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} diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 0fdec2a03..80d0a2a98 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,17 +22,13 @@ 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; @@ -58,17 +46,13 @@ export function Sidebar({ navItems, onNavItemClick, chats, + sharedChats = [], activeChatId, onNewChat, onChatSelect, onChatDelete, - onViewAllChats, - notes, - activeNoteId, - onNoteSelect, - onNoteDelete, - onAddNote, - onViewAllNotes, + onViewAllSharedChats, + onViewAllPrivateChats, user, onSettings, onManageMembers, @@ -143,7 +127,7 @@ export function Sidebar({ {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 +226,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/index.ts b/surfsense_web/components/layout/ui/sidebar/index.ts index d98b45ca5..89d542629 100644 --- a/surfsense_web/components/layout/ui/sidebar/index.ts +++ b/surfsense_web/components/layout/ui/sidebar/index.ts @@ -1,5 +1,6 @@ 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"; 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/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index 5849003e2..dcff4768b 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -11,7 +11,7 @@ enum ResponseType { } export type RequestOptions = { - method: "GET" | "POST" | "PUT" | "DELETE"; + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; headers?: Record; contentType?: "application/json" | "application/x-www-form-urlencoded"; signal?: AbortSignal; @@ -273,6 +273,21 @@ class BaseApiService { }); } + async patch( + url: string, + responseSchema?: ZodType, + options?: Omit + ) { + return this.request(url, responseSchema, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + ...options, + responseType: ResponseType.JSON, + }); + } + async getBlob(url: string, options?: Omit) { return this.request(url, undefined, { ...options, diff --git a/surfsense_web/lib/chat/thread-persistence.ts b/surfsense_web/lib/chat/thread-persistence.ts index b84c5907b..5c65ad47e 100644 --- a/surfsense_web/lib/chat/thread-persistence.ts +++ b/surfsense_web/lib/chat/thread-persistence.ts @@ -9,10 +9,17 @@ import { baseApiService } from "@/lib/apis/base-api.service"; // Types matching backend schemas // ============================================================================= +/** + * Chat visibility levels - matches backend ChatVisibility enum + */ +export type ChatVisibility = "PRIVATE" | "SEARCH_SPACE"; + export interface ThreadRecord { id: number; title: string; archived: boolean; + visibility: ChatVisibility; + created_by_id: string | null; search_space_id: number; created_at: string; updated_at: string; @@ -35,6 +42,9 @@ export interface ThreadListItem { id: number; title: string; archived: boolean; + visibility: ChatVisibility; + created_by_id: string | null; + is_own_thread: boolean; createdAt: string; updatedAt: string; } @@ -127,6 +137,25 @@ export async function deleteThread(threadId: number): Promise { await baseApiService.delete(`/api/v1/threads/${threadId}`); } +/** + * Update thread visibility (share/unshare) + */ +export async function updateThreadVisibility( + threadId: number, + visibility: ChatVisibility +): Promise { + return baseApiService.patch(`/api/v1/threads/${threadId}/visibility`, undefined, { + body: { visibility }, + }); +} + +/** + * Get full thread details including visibility + */ +export async function getThreadFull(threadId: number): Promise { + return baseApiService.get(`/api/v1/threads/${threadId}/full`); +} + // ============================================================================= // Thread List Manager (for thread list sidebar) // ============================================================================= diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 57f03a0fb..f46535ba7 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -312,6 +312,7 @@ "rows_per_page": "Rows per page", "refresh": "Refresh", "refresh_success": "Documents refreshed", + "create_shared_note": "Create Shared Note", "processing_documents": "Processing documents...", "active_tasks_count": "{count} active task(s)" }, @@ -628,11 +629,16 @@ "manage": "Manage" }, "sidebar": { + "chats": "Private Chats", + "shared_chats": "Shared Chats", "recent_chats": "Recent Chats", "search_chats": "Search chats...", "no_chats_found": "No chats found", "no_recent_chats": "No recent chats", + "no_shared_chats": "No shared chats", "view_all_chats": "View all chats", + "view_all_shared_chats": "View all shared chats", + "view_all_private_chats": "View all private chats", "all_chats": "All Chats", "all_chats_description": "Browse and manage all your chats", "no_chats": "No chats yet", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 89cb7813a..59dc0ca20 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -312,6 +312,7 @@ "rows_per_page": "每页行数", "refresh": "刷新", "refresh_success": "文档已刷新", + "create_shared_note": "创建共享笔记", "processing_documents": "正在处理文档...", "active_tasks_count": "{count} 个正在进行的工作项" }, @@ -628,11 +629,16 @@ "manage": "管理" }, "sidebar": { + "chats": "私人对话", + "shared_chats": "共享对话", "recent_chats": "最近对话", "search_chats": "搜索对话...", "no_chats_found": "未找到对话", "no_recent_chats": "暂无最近对话", + "no_shared_chats": "暂无共享对话", "view_all_chats": "查看所有对话", + "view_all_shared_chats": "查看所有共享对话", + "view_all_private_chats": "查看所有私人对话", "all_chats": "所有对话", "all_chats_description": "浏览和管理您的所有对话", "no_chats": "暂无对话",