diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index ff9a8675b..fb5808307 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -56,6 +56,7 @@ router = APIRouter() async def check_thread_access( + session: AsyncSession, thread: NewChatThread, user: User, require_ownership: bool = False, @@ -65,14 +66,16 @@ async def check_thread_access( 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 + - 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) - Legacy threads (NULL creator) are treated as accessible by all + 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 @@ -83,10 +86,31 @@ async def check_thread_access( 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: + # 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: @@ -96,13 +120,10 @@ async def check_thread_access( ) return True - # For read access: owner or shared threads + # For read access: owner can access their own private 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, @@ -129,7 +150,7 @@ async def list_threads( 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) + - 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 @@ -146,19 +167,32 @@ async def list_threads( "You don't have permission to read chats in this search space", ) - # Get threads that are either: + # 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 with no creator (created_by_id is NULL) - visible to all + # 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, - or_( - NewChatThread.created_by_id == user.id, - NewChatThread.visibility == ChatVisibility.SEARCH_SPACE, - NewChatThread.created_by_id.is_(None), # Legacy threads - ), + or_(*filter_conditions), ) .order_by(NewChatThread.updated_at.desc()) ) @@ -171,9 +205,9 @@ 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 + # 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, @@ -222,7 +256,7 @@ async def search_threads( 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) + - 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 @@ -239,17 +273,31 @@ async def search_threads( "You don't have permission to read chats 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 + 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_( - NewChatThread.created_by_id == user.id, - NewChatThread.visibility == ChatVisibility.SEARCH_SPACE, - NewChatThread.created_by_id.is_(None), # Legacy threads - ), + or_(*filter_conditions), ) .order_by(NewChatThread.updated_at.desc()) ) @@ -264,9 +312,10 @@ async def search_threads( archived=thread.archived, visibility=thread.visibility, created_by_id=thread.created_by_id, - # Legacy threads (no creator) are treated as own 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 + 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, @@ -383,7 +432,7 @@ async def get_thread_messages( ) # Check thread-level access based on visibility - await check_thread_access(thread, user) + await check_thread_access(session, thread, user) # Return messages in the format expected by assistant-ui messages = [ @@ -447,7 +496,7 @@ async def get_thread_full( ) # Check thread-level access based on visibility - await check_thread_access(thread, user) + await check_thread_access(session, thread, user) return thread @@ -500,7 +549,7 @@ async def update_thread( # 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) + await check_thread_access(session, db_thread, user, require_ownership=True) # Update fields update_data = thread_update.model_dump(exclude_unset=True) @@ -568,7 +617,7 @@ async def delete_thread( # 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 check_thread_access(session, db_thread, user, require_ownership=True) await session.delete(db_thread) await session.commit() @@ -628,7 +677,7 @@ async def update_thread_visibility( ) # Only the creator can change visibility - await check_thread_access(db_thread, user, require_ownership=True) + await check_thread_access(session, db_thread, user, require_ownership=True) # Update visibility db_thread.visibility = visibility_update.visibility @@ -714,7 +763,7 @@ async def append_message( ) # Check thread-level access based on visibility - await check_thread_access(thread, user) + await check_thread_access(session, thread, user) # Convert string role to enum role_str = ( @@ -825,7 +874,7 @@ async def list_messages( ) # Check thread-level access based on visibility - await check_thread_access(thread, user) + await check_thread_access(session, thread, user) # Get messages query = ( @@ -894,7 +943,7 @@ async def handle_new_chat( ) # Check thread-level access based on visibility - await check_thread_access(thread, user) + await check_thread_access(session, thread, user) # Get search space to check LLM config preferences search_space_result = await session.execute( diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 99b6e8703..a74f134dc 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -77,29 +77,31 @@ def format_mentioned_surfsense_docs_as_context( 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( + f" " + ) context_parts.append("") context_parts.append("") context_parts.append("") - - if hasattr(doc, 'chunks') and doc.chunks: + + if hasattr(doc, "chunks") and doc.chunks: for chunk in doc.chunks: context_parts.append( f" " @@ -108,11 +110,11 @@ def format_mentioned_surfsense_docs_as_context( context_parts.append( f" " ) - + context_parts.append("") context_parts.append("") context_parts.append("") - + context_parts.append("") return "\n".join(context_parts) @@ -263,6 +265,7 @@ async def stream_new_chat( 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)) 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 742be6ff4..7d266cafe 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 @@ -65,8 +65,7 @@ export default function DocumentsTable() { ); // Check if only SURFSENSE_DOCS is selected (skip regular docs query) - const onlySurfsenseDocsSelected = - activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS"; + const onlySurfsenseDocsSelected = activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS"; // Build query parameters for fetching documents (excluding SURFSENSE_DOCS type) const queryParams = useMemo( @@ -130,9 +129,11 @@ export default function DocumentsTable() { queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize], queryFn: () => documentsApiService.getSurfsenseDocs({ - page: pageIndex, - page_size: pageSize, - title: debouncedSearch.trim() || undefined, + queryParams: { + page: pageIndex, + page_size: pageSize, + title: debouncedSearch.trim() || undefined, + }, }), staleTime: 3 * 60 * 1000, // 3 minutes enabled: showSurfsenseDocs, @@ -256,7 +257,16 @@ export default function DocumentsTable() { } finally { setIsRefreshing(false); } - }, [debouncedSearch, refetchSearch, refetchDocuments, refetchSurfsenseDocs, showSurfsenseDocs, onlySurfsenseDocsSelected, t, isRefreshing]); + }, [ + debouncedSearch, + refetchSearch, + refetchDocuments, + refetchSurfsenseDocs, + showSurfsenseDocs, + onlySurfsenseDocsSelected, + t, + isRefreshing, + ]); // Set up smart polling for active tasks - only polls when tasks are in progress const { summary } = useLogsSummary(searchSpaceId, 24, { diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 693aa3061..fbb92ae2c 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -459,7 +459,9 @@ export default function NewChatPage() { // Track message sent trackChatMessageSent(searchSpaceId, currentThreadId, { hasAttachments: messageAttachments.length > 0, - hasMentionedDocuments: mentionedDocumentIds.surfsense_doc_ids.length > 0 || mentionedDocumentIds.document_ids.length > 0, + hasMentionedDocuments: + mentionedDocumentIds.surfsense_doc_ids.length > 0 || + mentionedDocumentIds.document_ids.length > 0, messageLength: userQuery.length, }); @@ -683,7 +685,9 @@ export default function NewChatPage() { messages: messageHistory, attachments: attachments.length > 0 ? attachments : undefined, mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_ids : undefined, - mentioned_surfsense_doc_ids: hasSurfsenseDocIds ? mentionedDocumentIds.surfsense_doc_ids : undefined, + mentioned_surfsense_doc_ids: hasSurfsenseDocIds + ? mentionedDocumentIds.surfsense_doc_ids + : undefined, }), signal: controller.signal, }); diff --git a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx index 26cb882e2..fb2f49317 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx @@ -170,7 +170,9 @@ function SettingsSidebar({ > {t(item.labelKey)}

-

{t(item.descriptionKey)}

+

+ {t(item.descriptionKey)} +

- ); diff --git a/surfsense_web/app/dashboard/user/settings/page.tsx b/surfsense_web/app/dashboard/user/settings/page.tsx index d09428a52..bf88e65e5 100644 --- a/surfsense_web/app/dashboard/user/settings/page.tsx +++ b/surfsense_web/app/dashboard/user/settings/page.tsx @@ -160,7 +160,6 @@ function UserSettingsSidebar({ ); })} - ); diff --git a/surfsense_web/atoms/chat/mentioned-documents.atom.ts b/surfsense_web/atoms/chat/mentioned-documents.atom.ts index 17ae38616..aba2736e5 100644 --- a/surfsense_web/atoms/chat/mentioned-documents.atom.ts +++ b/surfsense_web/atoms/chat/mentioned-documents.atom.ts @@ -19,7 +19,7 @@ export const mentionedDocumentIdsAtom = atom<{ * Atom to store the full document objects mentioned in the current chat composer. * This persists across component remounts. */ -export const mentionedDocumentsAtom = atom<(Pick)[]>([]); +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 417f7c70f..0e8c5bca5 100644 --- a/surfsense_web/components/assistant-ui/composer.tsx +++ b/surfsense_web/components/assistant-ui/composer.tsx @@ -54,8 +54,12 @@ export const Composer: FC = () => { // Sync mentioned document IDs to atom for use in chat request useEffect(() => { 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), + 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]); @@ -138,12 +142,14 @@ export const Composer: FC = () => { const handleDocumentRemove = useCallback( (docId: number, docType?: string) => { setMentionedDocuments((prev) => { - const updated = prev.filter( - (doc) => !(doc.id === docId && doc.document_type === docType) - ); + 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), + 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; }); @@ -153,9 +159,7 @@ export const Composer: FC = () => { const handleDocumentsMention = useCallback( (documents: Pick[]) => { - const existingKeys = new Set( - mentionedDocuments.map((d) => `${d.document_type}:${d.id}`) - ); + const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)); const newDocs = documents.filter( (doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`) ); @@ -171,8 +175,12 @@ export const Composer: FC = () => { ); const updated = [...prev, ...uniqueNewDocs]; 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), + 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; }); diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 4fa847a95..f35019216 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -100,7 +100,9 @@ export const InlineMentionEditor = forwardRef { if (initialDocuments.length > 0) { - setMentionedDocs(new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))); + setMentionedDocs( + new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d])) + ); } }, [initialDocuments]); diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx index 880832f1d..5700fbc2e 100644 --- a/surfsense_web/components/assistant-ui/markdown-text.tsx +++ b/surfsense_web/components/assistant-ui/markdown-text.tsx @@ -17,7 +17,7 @@ import { cn } from "@/lib/utils"; // Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID] // Also matches Chinese brackets 【】 and handles zero-width spaces that LLM sometimes inserts -const CITATION_REGEX = /[\[【]\u200B?citation:(doc-)?(\d+)\u200B?[\]】]/g; +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 diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 92b8ad786..bf46e3d97 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -230,8 +230,12 @@ const Composer: FC = () => { // Sync mentioned document IDs to atom for use in chat request useEffect(() => { 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), + 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]); @@ -314,12 +318,14 @@ const Composer: FC = () => { const handleDocumentRemove = useCallback( (docId: number, docType?: string) => { setMentionedDocuments((prev) => { - const updated = prev.filter( - (doc) => !(doc.id === docId && doc.document_type === docType) - ); + 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), + 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; }); @@ -329,9 +335,7 @@ const Composer: FC = () => { const handleDocumentsMention = useCallback( (documents: Pick[]) => { - const existingKeys = new Set( - mentionedDocuments.map((d) => `${d.document_type}:${d.id}`) - ); + const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)); const newDocs = documents.filter( (doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`) ); @@ -347,8 +351,12 @@ const Composer: FC = () => { ); const updated = [...prev, ...uniqueNewDocs]; 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), + 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; }); diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index ce86deca5..dbc9c5f6a 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -86,6 +86,11 @@ export function LayoutDataProvider({ const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [isDeletingChat, setIsDeletingChat] = useState(false); + // Delete search space dialog state + const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false); + const [searchSpaceToDelete, setSearchSpaceToDelete] = useState(null); + const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false); + const searchSpaces: SearchSpace[] = useMemo(() => { if (!searchSpacesData || !Array.isArray(searchSpacesData)) return []; return searchSpacesData.map((space) => ({ @@ -169,27 +174,46 @@ export function LayoutDataProvider({ }, [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) => { + setSearchSpaceToDelete(space); + setShowDeleteSearchSpaceDialog(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 handleNavItemClick = useCallback( (item: NavItem) => { @@ -284,6 +308,8 @@ export function LayoutDataProvider({ searchSpaces={searchSpaces} activeSearchSpaceId={Number(searchSpaceId)} onSearchSpaceSelect={handleSearchSpaceSelect} + onSearchSpaceDelete={handleSearchSpaceDeleteClick} + onSearchSpaceSettings={handleSearchSpaceSettings} onAddSearchSpace={handleAddSearchSpace} searchSpace={activeSearchSpace} navItems={navItems} @@ -297,9 +323,9 @@ export function LayoutDataProvider({ onViewAllSharedChats={handleViewAllSharedChats} onViewAllPrivateChats={handleViewAllPrivateChats} user={{ email: user?.email || "", name: user?.email?.split("@")[0] }} - onSettings={handleSettings} - onManageMembers={handleManageMembers} - onUserSettings={handleUserSettings} + onSettings={handleSettings} + onManageMembers={handleManageMembers} + onUserSettings={handleUserSettings} onLogout={handleLogout} pageUsage={pageUsage} breadcrumb={breadcrumb} @@ -354,6 +380,48 @@ export function LayoutDataProvider({ + {/* Delete Search Space Dialog */} + + + + + + {t("delete_search_space")} + + + {t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })} + + + + + + + + + {/* All Shared Chats Sidebar */} 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/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 251a67765..ed3a09099 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -14,6 +14,8 @@ 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[]; @@ -46,6 +48,8 @@ export function LayoutShell({ searchSpaces, activeSearchSpaceId, onSearchSpaceSelect, + onSearchSpaceDelete, + onSearchSpaceSettings, onAddSearchSpace, searchSpace, navItems, @@ -96,6 +100,8 @@ export function LayoutShell({ searchSpaces={searchSpaces} activeSearchSpaceId={activeSearchSpaceId} onSearchSpaceSelect={onSearchSpaceSelect} + onSearchSpaceDelete={onSearchSpaceDelete} + onSearchSpaceSettings={onSearchSpaceSettings} onAddSearchSpace={onAddSearchSpace} searchSpace={searchSpace} navItems={navItems} @@ -133,6 +139,8 @@ export function LayoutShell({ searchSpaces={searchSpaces} activeSearchSpaceId={activeSearchSpaceId} onSearchSpaceSelect={onSearchSpaceSelect} + onSearchSpaceDelete={onSearchSpaceDelete} + onSearchSpaceSettings={onSearchSpaceSettings} onAddSearchSpace={onAddSearchSpace} /> diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index a57fd045c..57fba60c9 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -13,6 +13,8 @@ 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[]; @@ -48,6 +50,8 @@ export function MobileSidebar({ searchSpaces, activeSearchSpaceId, onSearchSpaceSelect, + onSearchSpaceDelete, + onSearchSpaceSettings, onAddSearchSpace, searchSpace, navItems, @@ -94,7 +98,13 @@ export function MobileSidebar({ 1} + isOwner={space.isOwner} onClick={() => handleSearchSpaceSelect(space.id)} + onDelete={onSearchSpaceDelete ? () => onSearchSpaceDelete(space) : undefined} + onSettings={ + onSearchSpaceSettings ? () => onSearchSpaceSettings(space) : undefined + } size="md" /> @@ -111,33 +121,33 @@ export function MobileSidebar({ - {/* Sidebar Content */} -
- { - onNewChat(); - onOpenChange(false); - }} - onChatSelect={handleChatSelect} - onChatDelete={onChatDelete} - onViewAllSharedChats={onViewAllSharedChats} - onViewAllPrivateChats={onViewAllPrivateChats} - user={user} - onSettings={onSettings} - onManageMembers={onManageMembers} - onUserSettings={onUserSettings} - onLogout={onLogout} - pageUsage={pageUsage} - className="w-full border-none" - /> -
+ {/* Sidebar Content */} +
+ { + onNewChat(); + onOpenChange(false); + }} + onChatSelect={handleChatSelect} + onChatDelete={onChatDelete} + onViewAllSharedChats={onViewAllSharedChats} + onViewAllPrivateChats={onViewAllPrivateChats} + user={user} + onSettings={onSettings} + onManageMembers={onManageMembers} + onUserSettings={onUserSettings} + onLogout={onLogout} + pageUsage={pageUsage} + className="w-full border-none" + /> +
); diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index 8b84f3218..e89885b1d 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -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); @@ -128,7 +130,7 @@ export const DocumentMentionPicker = forwardRef< useEffect(() => { if (currentPage === 0) { const combinedDocs: Pick[] = []; - + // Add SurfSense docs first (they appear at top) if (surfsenseDocs?.items) { for (const doc of surfsenseDocs.items) { @@ -139,7 +141,7 @@ export const DocumentMentionPicker = forwardRef< }); } } - + // Add regular documents if (debouncedSearch.trim()) { if (searchedDocuments?.items) { @@ -152,7 +154,7 @@ export const DocumentMentionPicker = forwardRef< setHasMore(documents.has_more); } } - + setAccumulatedDocuments(combinedDocs); } }, [documents, searchedDocuments, surfsenseDocs, debouncedSearch, currentPage]); @@ -209,7 +211,9 @@ export const DocumentMentionPicker = forwardRef< const actualDocuments = accumulatedDocuments; const actualLoading = - ((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) || isSurfsenseDocsLoading) && currentPage === 0; + ((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) || + isSurfsenseDocsLoading) && + currentPage === 0; // Track already selected documents using unique key (document_type:id) to avoid ID collisions const selectedKeys = useMemo( diff --git a/surfsense_web/components/ui/context-menu.tsx b/surfsense_web/components/ui/context-menu.tsx new file mode 100644 index 000000000..7c76f7fd0 --- /dev/null +++ b/surfsense_web/components/ui/context-menu.tsx @@ -0,0 +1,225 @@ +"use client"; + +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function ContextMenu({ ...props }: React.ComponentProps) { + return ; +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function ContextMenuGroup({ ...props }: React.ComponentProps) { + return ; +} + +function ContextMenuPortal({ ...props }: React.ComponentProps) { + return ; +} + +function ContextMenuSub({ ...props }: React.ComponentProps) { + return ; +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +}; diff --git a/surfsense_web/lib/apis/documents-api.service.ts b/surfsense_web/lib/apis/documents-api.service.ts index e1047c386..bea399f98 100644 --- a/surfsense_web/lib/apis/documents-api.service.ts +++ b/surfsense_web/lib/apis/documents-api.service.ts @@ -19,6 +19,7 @@ import { getDocumentTypeCountsRequest, getDocumentTypeCountsResponse, getSurfsenseDocsByChunkResponse, + getSurfsenseDocsRequest, getSurfsenseDocsResponse, type SearchDocumentsRequest, searchDocumentsRequest, @@ -29,7 +30,6 @@ import { updateDocumentResponse, uploadDocumentRequest, uploadDocumentResponse, - getSurfsenseDocsRequest, } from "@/contracts/types/document.types"; import { ValidationError } from "../error"; import { baseApiService } from "./base-api.service"; diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 9b59d5043..d55f70bf7 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -97,6 +97,9 @@ "create_new_search_space": "Create new search space", "delete_title": "Delete Search Space", "delete_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone and will permanently remove all data.", + "leave": "Leave", + "leave_title": "Leave Search Space", + "leave_confirm": "Are you sure you want to leave \"{name}\"? You will lose access to all documents and chats in this search space.", "welcome_title": "Welcome to SurfSense", "welcome_description": "Create your first search space to start organizing your knowledge, connecting sources, and chatting with AI.", "create_first_button": "Create your first search space" diff --git a/surfsense_web/package.json b/surfsense_web/package.json index 3c98c47e0..c3ee214eb 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -37,6 +37,7 @@ "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.11", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml index a184b6cd0..c85ddbf13 100644 --- a/surfsense_web/pnpm-lock.yaml +++ b/surfsense_web/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@radix-ui/react-collapsible': specifier: ^1.1.11 version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-context-menu': + specifier: ^2.2.16 + version: 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1628,6 +1631,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-context@1.0.0': resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==} peerDependencies: @@ -7422,6 +7438,20 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-context@1.0.0(react@19.2.3)': dependencies: '@babel/runtime': 7.28.4