From 4ace7d09a0a3ea0958f65c55bfc7e4ae414bf62f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 13 Jan 2026 00:49:27 +0200 Subject: [PATCH 01/18] Remove all search spaces sheet and dropdown option --- surfsense_web/components/layout/index.ts | 1 - .../layout/providers/LayoutDataProvider.tsx | 29 +-- .../components/layout/types/layout.types.ts | 1 - surfsense_web/components/layout/ui/index.ts | 1 - .../layout/ui/sheets/AllSearchSpacesSheet.tsx | 241 ------------------ .../components/layout/ui/sheets/index.ts | 1 - .../layout/ui/shell/LayoutShell.tsx | 4 - .../layout/ui/sidebar/MobileSidebar.tsx | 19 +- .../components/layout/ui/sidebar/Sidebar.tsx | 3 - .../layout/ui/sidebar/SidebarHeader.tsx | 9 +- 10 files changed, 13 insertions(+), 296 deletions(-) delete mode 100644 surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx delete mode 100644 surfsense_web/components/layout/ui/sheets/index.ts diff --git a/surfsense_web/components/layout/index.ts b/surfsense_web/components/layout/index.ts index 18f8cc9d3..7a57bf73f 100644 --- a/surfsense_web/components/layout/index.ts +++ b/surfsense_web/components/layout/index.ts @@ -11,7 +11,6 @@ export type { User, } from "./types/layout.types"; export { - AllSearchSpacesSheet, ChatListItem, CreateSearchSpaceDialog, Header, diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 70bc96f58..c22af5962 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -28,7 +28,6 @@ import { resetUser, trackLogout } from "@/lib/posthog/events"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import type { ChatItem, NavItem, NoteItem, SearchSpace } from "../types/layout.types"; import { CreateSearchSpaceDialog } from "../ui/dialogs"; -import { AllSearchSpacesSheet } from "../ui/sheets"; import { LayoutShell } from "../ui/shell"; import { AllChatsSidebar } from "../ui/sidebar/AllChatsSidebar"; import { AllNotesSidebar } from "../ui/sidebar/AllNotesSidebar"; @@ -114,8 +113,7 @@ export function LayoutDataProvider({ const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false); const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false); - // Search space sheet and dialog state - const [isAllSearchSpacesSheetOpen, setIsAllSearchSpacesSheetOpen] = useState(false); + // Search space dialog state const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); // Delete dialogs state @@ -210,10 +208,6 @@ export function LayoutDataProvider({ setIsCreateSearchSpaceDialogOpen(true); }, []); - const handleSeeAllSearchSpaces = useCallback(() => { - setIsAllSearchSpacesSheetOpen(true); - }, []); - const handleUserSettings = useCallback(() => { router.push("/dashboard/user/settings"); }, [router]); @@ -397,10 +391,9 @@ export function LayoutDataProvider({ onAddNote={handleAddNote} onViewAllNotes={handleViewAllNotes} user={{ email: user?.email || "", name: user?.email?.split("@")[0] }} - onSettings={handleSettings} - onManageMembers={handleManageMembers} - onSeeAllSearchSpaces={handleSeeAllSearchSpaces} - onUserSettings={handleUserSettings} + onSettings={handleSettings} + onManageMembers={handleManageMembers} + onUserSettings={handleUserSettings} onLogout={handleLogout} pageUsage={pageUsage} breadcrumb={breadcrumb} @@ -470,20 +463,6 @@ export function LayoutDataProvider({ onAddNote={handleAddNote} /> - {/* All Search Spaces Sheet */} - { - setIsAllSearchSpacesSheetOpen(false); - setIsCreateSearchSpaceDialogOpen(true); - }} - onSettings={handleSearchSpaceSettings} - onDelete={handleDeleteSearchSpace} - /> - {/* Create Search Space Dialog */} void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; onToggleTheme?: () => void; onLogout?: () => void; pageUsage?: PageUsage; diff --git a/surfsense_web/components/layout/ui/index.ts b/surfsense_web/components/layout/ui/index.ts index bd3d54838..1c3ddb2ca 100644 --- a/surfsense_web/components/layout/ui/index.ts +++ b/surfsense_web/components/layout/ui/index.ts @@ -1,7 +1,6 @@ export { CreateSearchSpaceDialog } from "./dialogs"; export { Header } from "./header"; export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail"; -export { AllSearchSpacesSheet } from "./sheets"; export { LayoutShell } from "./shell"; export { ChatListItem, diff --git a/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx b/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx deleted file mode 100644 index 401de41c3..000000000 --- a/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx +++ /dev/null @@ -1,241 +0,0 @@ -"use client"; - -import { - Calendar, - MoreHorizontal, - Search, - Settings, - Share2, - Trash2, - UserCheck, - Users, -} from "lucide-react"; -import { useTranslations } from "next-intl"; -import { useState } from "react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; -import type { SearchSpace } from "../../types/layout.types"; - -function formatDate(dateString: string): string { - return new Date(dateString).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); -} - -interface AllSearchSpacesSheetProps { - open: boolean; - onOpenChange: (open: boolean) => void; - searchSpaces: SearchSpace[]; - onSearchSpaceSelect: (id: number) => void; - onCreateNew?: () => void; - onSettings?: (id: number) => void; - onDelete?: (id: number) => void; -} - -export function AllSearchSpacesSheet({ - open, - onOpenChange, - searchSpaces, - onSearchSpaceSelect, - onCreateNew, - onSettings, - onDelete, -}: AllSearchSpacesSheetProps) { - const t = useTranslations("searchSpace"); - const tCommon = useTranslations("common"); - - const [spaceToDelete, setSpaceToDelete] = useState(null); - - const handleSelect = (id: number) => { - onSearchSpaceSelect(id); - onOpenChange(false); - }; - - const handleSettings = (e: React.MouseEvent, space: SearchSpace) => { - e.stopPropagation(); - onOpenChange(false); - onSettings?.(space.id); - }; - - const handleDeleteClick = (e: React.MouseEvent, space: SearchSpace) => { - e.stopPropagation(); - setSpaceToDelete(space); - }; - - const confirmDelete = () => { - if (spaceToDelete) { - onDelete?.(spaceToDelete.id); - setSpaceToDelete(null); - } - }; - - return ( - <> - - - -
-
- -
-
- {t("all_search_spaces")} - - {t("search_spaces_count", { count: searchSpaces.length })} - -
-
-
- -
- {searchSpaces.length === 0 ? ( -
-
- -
-
-

{t("no_search_spaces")}

-

{t("create_first_search_space")}

-
- {onCreateNew && ( - - )} -
- ) : ( - searchSpaces.map((space) => ( - - - - handleSettings(e, space)}> - - {tCommon("settings")} - - - handleDeleteClick(e, space)} - className="text-destructive focus:text-destructive" - > - - {tCommon("delete")} - - - - )} -
- - -
- - {space.isOwner ? ( - - ) : ( - - )} - {t("members_count", { count: space.memberCount })} - - {space.createdAt && ( - - - {formatDate(space.createdAt)} - - )} -
- - )) - )} - - - {searchSpaces.length > 0 && onCreateNew && ( -
- -
- )} -
-
- - !open && setSpaceToDelete(null)}> - - - {t("delete_title")} - - {t("delete_confirm", { name: spaceToDelete?.name ?? "" })} - - - - {tCommon("cancel")} - - {tCommon("delete")} - - - - - - ); -} diff --git a/surfsense_web/components/layout/ui/sheets/index.ts b/surfsense_web/components/layout/ui/sheets/index.ts deleted file mode 100644 index d3db749bb..000000000 --- a/surfsense_web/components/layout/ui/sheets/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AllSearchSpacesSheet } from "./AllSearchSpacesSheet"; diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 1bb0a015a..d3fdafa5c 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -40,7 +40,6 @@ interface LayoutShellProps { user: User; onSettings?: () => void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; onUserSettings?: () => void; onLogout?: () => void; pageUsage?: PageUsage; @@ -77,7 +76,6 @@ export function LayoutShell({ user, onSettings, onManageMembers, - onSeeAllSearchSpaces, onUserSettings, onLogout, pageUsage, @@ -132,7 +130,6 @@ export function LayoutShell({ user={user} onSettings={onSettings} onManageMembers={onManageMembers} - onSeeAllSearchSpaces={onSeeAllSearchSpaces} onUserSettings={onUserSettings} onLogout={onLogout} pageUsage={pageUsage} @@ -181,7 +178,6 @@ export function LayoutShell({ user={user} onSettings={onSettings} onManageMembers={onManageMembers} - onSeeAllSearchSpaces={onSeeAllSearchSpaces} onUserSettings={onUserSettings} onLogout={onLogout} pageUsage={pageUsage} diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index c1874bfd1..cae81f613 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -40,7 +40,6 @@ interface MobileSidebarProps { user: User; onSettings?: () => void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; onUserSettings?: () => void; onLogout?: () => void; pageUsage?: PageUsage; @@ -80,7 +79,6 @@ export function MobileSidebar({ user, onSettings, onManageMembers, - onSeeAllSearchSpaces, onUserSettings, onLogout, pageUsage, @@ -141,15 +139,14 @@ export function MobileSidebar({ onNoteDelete={onNoteDelete} onAddNote={onAddNote} onViewAllNotes={onViewAllNotes} - user={user} - onSettings={onSettings} - onManageMembers={onManageMembers} - onSeeAllSearchSpaces={onSeeAllSearchSpaces} - onUserSettings={onUserSettings} - onLogout={onLogout} - pageUsage={pageUsage} - className="w-full border-none" - /> + user={user} + onSettings={onSettings} + onManageMembers={onManageMembers} + onUserSettings={onUserSettings} + onLogout={onLogout} + pageUsage={pageUsage} + className="w-full border-none" + /> diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 0fdec2a03..5bc1e62b9 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -44,7 +44,6 @@ interface SidebarProps { user: User; onSettings?: () => void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; onUserSettings?: () => void; onLogout?: () => void; pageUsage?: PageUsage; @@ -72,7 +71,6 @@ export function Sidebar({ user, onSettings, onManageMembers, - onSeeAllSearchSpaces, onUserSettings, onLogout, pageUsage, @@ -103,7 +101,6 @@ export function Sidebar({ isCollapsed={isCollapsed} onSettings={onSettings} onManageMembers={onManageMembers} - onSeeAllSearchSpaces={onSeeAllSearchSpaces} />
void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; className?: string; } @@ -27,7 +26,6 @@ export function SidebarHeader({ isCollapsed, onSettings, onManageMembers, - onSeeAllSearchSpaces, className, }: SidebarHeaderProps) { const t = useTranslations("sidebar"); @@ -59,11 +57,6 @@ export function SidebarHeader({ {t("search_space_settings")} - - - - {t("see_all_search_spaces")} -
From 738e23b51ab20eb2b82887409e03b8c23514d2d1 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 13 Jan 2026 01:15:33 +0200 Subject: [PATCH 02/18] Add SurfSense docs to documents table --- .../app/routes/surfsense_docs_routes.py | 82 +++++++++- .../app/schemas/surfsense_docs.py | 15 ++ .../(manage)/components/DocumentsFilters.tsx | 2 +- .../components/DocumentsTableShell.tsx | 24 ++- .../(manage)/components/RowActions.tsx | 69 ++++---- .../documents/(manage)/page.tsx | 153 +++++++++++++++--- .../contracts/enums/connectorIcons.tsx | 3 + .../contracts/types/document.types.ts | 23 +++ .../lib/apis/documents-api.service.ts | 26 +++ 9 files changed, 338 insertions(+), 59 deletions(-) diff --git a/surfsense_backend/app/routes/surfsense_docs_routes.py b/surfsense_backend/app/routes/surfsense_docs_routes.py index a2de65568..e1713e8a3 100644 --- a/surfsense_backend/app/routes/surfsense_docs_routes.py +++ b/surfsense_backend/app/routes/surfsense_docs_routes.py @@ -7,7 +7,7 @@ on a [citation:doc-XXX] link. """ from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -17,8 +17,10 @@ from app.db import ( User, get_async_session, ) +from app.schemas import PaginatedResponse from app.schemas.surfsense_docs import ( SurfsenseDocsChunkRead, + SurfsenseDocsDocumentRead, SurfsenseDocsDocumentWithChunksRead, ) from app.users import current_active_user @@ -87,3 +89,81 @@ async def get_surfsense_doc_by_chunk_id( status_code=500, detail=f"Failed to retrieve Surfsense documentation: {e!s}", ) from e + + +@router.get( + "/surfsense-docs", + response_model=PaginatedResponse[SurfsenseDocsDocumentRead], +) +async def list_surfsense_docs( + page: int = 0, + page_size: int = 50, + title: str | None = None, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + List all Surfsense documentation documents. + + Args: + page: Zero-based page index. + page_size: Number of items per page (default: 50). + title: Optional title filter (case-insensitive substring match). + session: Database session (injected). + user: Current authenticated user (injected). + + Returns: + PaginatedResponse[SurfsenseDocsDocumentRead]: Paginated list of Surfsense docs. + """ + try: + # Base query + query = select(SurfsenseDocsDocument) + count_query = select(func.count()).select_from(SurfsenseDocsDocument) + + # Filter by title if provided + if title and title.strip(): + query = query.filter(SurfsenseDocsDocument.title.ilike(f"%{title}%")) + count_query = count_query.filter( + SurfsenseDocsDocument.title.ilike(f"%{title}%") + ) + + # Get total count + total_result = await session.execute(count_query) + total = total_result.scalar() or 0 + + # Calculate offset + offset = page * page_size + + # Get paginated results + result = await session.execute( + query.order_by(SurfsenseDocsDocument.title).offset(offset).limit(page_size) + ) + docs = result.scalars().all() + + # Convert to response format + items = [ + SurfsenseDocsDocumentRead( + id=doc.id, + title=doc.title, + source=doc.source, + content=doc.content, + created_at=doc.created_at, + updated_at=doc.updated_at, + ) + for doc in docs + ] + + has_more = (offset + len(items)) < total + + return PaginatedResponse( + items=items, + total=total, + page=page, + page_size=page_size, + has_more=has_more, + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to list Surfsense documentation: {e!s}", + ) from e diff --git a/surfsense_backend/app/schemas/surfsense_docs.py b/surfsense_backend/app/schemas/surfsense_docs.py index c6029320f..ce32c0ef8 100644 --- a/surfsense_backend/app/schemas/surfsense_docs.py +++ b/surfsense_backend/app/schemas/surfsense_docs.py @@ -2,6 +2,8 @@ Schemas for Surfsense documentation. """ +from datetime import datetime + from pydantic import BaseModel, ConfigDict @@ -14,6 +16,19 @@ class SurfsenseDocsChunkRead(BaseModel): model_config = ConfigDict(from_attributes=True) +class SurfsenseDocsDocumentRead(BaseModel): + """Schema for a Surfsense docs document (without chunks).""" + + id: int + title: str + source: str + content: str + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = ConfigDict(from_attributes=True) + + class SurfsenseDocsDocumentWithChunksRead(BaseModel): """Schema for a Surfsense docs document with its chunks.""" diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx index 4adb5414c..67413d6f0 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx @@ -47,7 +47,7 @@ export function DocumentsFilters({ columnVisibility, onToggleColumn, }: { - typeCounts: Record; + typeCounts: Partial>; selectedIds: Set; onSearch: (v: string) => void; searchValue: string; diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index 94c0626e6..566e103ac 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -79,17 +79,25 @@ export function DocumentsTableShell({ [documents, sortKey, sortDesc] ); - const allSelectedOnPage = sorted.length > 0 && sorted.every((d) => selectedIds.has(d.id)); - const someSelectedOnPage = sorted.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage; + // Filter out SURFSENSE_DOCS for selection purposes + const selectableDocs = React.useMemo( + () => sorted.filter((d) => d.document_type !== "SURFSENSE_DOCS"), + [sorted] + ); + + const allSelectedOnPage = + selectableDocs.length > 0 && selectableDocs.every((d) => selectedIds.has(d.id)); + const someSelectedOnPage = + selectableDocs.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage; const toggleAll = (checked: boolean) => { const next = new Set(selectedIds); if (checked) - sorted.forEach((d) => { + selectableDocs.forEach((d) => { next.add(d.id); }); else - sorted.forEach((d) => { + selectableDocs.forEach((d) => { next.delete(d.id); }); setSelectedIds(next); @@ -230,9 +238,10 @@ export function DocumentsTableShell({ const icon = getDocumentTypeIcon(doc.document_type); const title = doc.title; const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title; + const isSurfsenseDoc = doc.document_type === "SURFSENSE_DOCS"; return ( toggleOne(doc.id, !!v)} + checked={selectedIds.has(doc.id) && !isSurfsenseDoc} + onCheckedChange={(v) => !isSurfsenseDoc && toggleOne(doc.id, !!v)} + disabled={isSurfsenseDoc} aria-label="Select row" /> diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx index 2fe9ab3e8..d277a84ee 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx @@ -28,6 +28,9 @@ import type { Document } from "./types"; // Only FILE and NOTE document types can be edited const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const; +// SURFSENSE_DOCS are system-managed and cannot be deleted +const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const; + export function RowActions({ document, deleteDocument, @@ -48,6 +51,10 @@ export function RowActions({ document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number] ); + const isDeletable = !NON_DELETABLE_DOCUMENT_TYPES.includes( + document.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number] + ); + const handleDelete = async () => { setIsDeleting(true); try { @@ -120,29 +127,31 @@ export function RowActions({ - - - - - - - -

Delete

-
-
+ + + + +

Delete

+
+ + )} {/* Mobile Actions Dropdown */} @@ -165,13 +174,15 @@ export function RowActions({ Metadata - setIsDeleteOpen(true)} - className="text-destructive focus:text-destructive" - > - - Delete - + {isDeletable && ( + setIsDeleteOpen(true)} + className="text-destructive focus:text-destructive" + > + + Delete + + )} diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx index 019f5796a..368f0f654 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 @@ -19,7 +19,7 @@ import { DocumentsFilters } from "./components/DocumentsFilters"; import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell"; import { PaginationControls } from "./components/PaginationControls"; import { ProcessingIndicator } from "./components/ProcessingIndicator"; -import type { ColumnVisibility } from "./components/types"; +import type { ColumnVisibility, Document } from "./components/types"; function useDebounced(value: T, delay = 250) { const [debounced, setDebounced] = useState(value); @@ -50,33 +50,43 @@ export default function DocumentsTable() { const [sortKey, setSortKey] = useState("title"); const [sortDesc, setSortDesc] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); - const { data: typeCounts } = useAtomValue(documentTypeCountsAtom); + const { data: rawTypeCounts } = useAtomValue(documentTypeCountsAtom); const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); - // Build query parameters for fetching documents + // Filter out SURFSENSE_DOCS from active types for regular documents API + const regularDocumentTypes = useMemo( + () => activeTypes.filter((t) => t !== "SURFSENSE_DOCS"), + [activeTypes] + ); + + // Check if only SURFSENSE_DOCS is selected (skip regular docs query) + const onlySurfsenseDocsSelected = + activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS"; + + // Build query parameters for fetching documents (excluding SURFSENSE_DOCS type) const queryParams = useMemo( () => ({ search_space_id: searchSpaceId, page: pageIndex, page_size: pageSize, - ...(activeTypes.length > 0 && { document_types: activeTypes }), + ...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }), }), - [searchSpaceId, pageIndex, pageSize, activeTypes] + [searchSpaceId, pageIndex, pageSize, regularDocumentTypes] ); - // Build search query parameters + // Build search query parameters (excluding SURFSENSE_DOCS type) const searchQueryParams = useMemo( () => ({ search_space_id: searchSpaceId, page: pageIndex, page_size: pageSize, title: debouncedSearch.trim(), - ...(activeTypes.length > 0 && { document_types: activeTypes }), + ...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }), }), - [searchSpaceId, pageIndex, pageSize, activeTypes, debouncedSearch] + [searchSpaceId, pageIndex, pageSize, regularDocumentTypes, debouncedSearch] ); - // Use query for fetching documents + // Use query for fetching documents (disabled when only SURFSENSE_DOCS is selected) const { data: documentsResponse, isLoading: isDocumentsLoading, @@ -86,10 +96,10 @@ export default function DocumentsTable() { queryKey: cacheKeys.documents.globalQueryParams(queryParams), queryFn: () => documentsApiService.getDocuments({ queryParams }), staleTime: 3 * 60 * 1000, // 3 minutes - enabled: !!searchSpaceId && !debouncedSearch.trim(), + enabled: !!searchSpaceId && !debouncedSearch.trim() && !onlySurfsenseDocsSelected, }); - // Use query for searching documents + // Use query for searching documents (disabled when only SURFSENSE_DOCS is selected) const { data: searchResponse, isLoading: isSearchLoading, @@ -99,16 +109,109 @@ export default function DocumentsTable() { queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams), queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }), staleTime: 3 * 60 * 1000, // 3 minutes - enabled: !!searchSpaceId && !!debouncedSearch.trim(), + enabled: !!searchSpaceId && !!debouncedSearch.trim() && !onlySurfsenseDocsSelected, }); + // Determine if we should show SurfSense docs (when no type filter or SURFSENSE_DOCS is selected) + const showSurfsenseDocs = + activeTypes.length === 0 || activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum); + + // Use query for fetching SurfSense docs + const { + data: surfsenseDocsResponse, + isLoading: isSurfsenseDocsLoading, + refetch: refetchSurfsenseDocs, + } = useQuery({ + queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize], + queryFn: () => + documentsApiService.getSurfsenseDocs({ + page: pageIndex, + page_size: pageSize, + title: debouncedSearch.trim() || undefined, + }), + staleTime: 3 * 60 * 1000, // 3 minutes + enabled: showSurfsenseDocs, + }); + + // Transform SurfSense docs to match the Document type + const surfsenseDocsAsDocuments: Document[] = useMemo(() => { + if (!surfsenseDocsResponse?.items) return []; + return surfsenseDocsResponse.items.map((doc) => ({ + id: doc.id, + title: doc.title, + document_type: "SURFSENSE_DOCS", + document_metadata: { source: doc.source }, + content: doc.content, + created_at: doc.created_at || doc.updated_at || new Date().toISOString(), + search_space_id: -1, // Special value for global docs + })); + }, [surfsenseDocsResponse]); + + // Merge type counts with SURFSENSE_DOCS count + const typeCounts = useMemo(() => { + const counts = { ...(rawTypeCounts || {}) }; + if (surfsenseDocsResponse?.total) { + counts.SURFSENSE_DOCS = surfsenseDocsResponse.total; + } + return counts; + }, [rawTypeCounts, surfsenseDocsResponse?.total]); + // Extract documents and total based on search state - const documents = debouncedSearch.trim() + const regularDocuments = debouncedSearch.trim() ? searchResponse?.items || [] : documentsResponse?.items || []; - const total = debouncedSearch.trim() ? searchResponse?.total || 0 : documentsResponse?.total || 0; - const loading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading; - const error = debouncedSearch.trim() ? searchError : documentsError; + const regularTotal = debouncedSearch.trim() + ? searchResponse?.total || 0 + : documentsResponse?.total || 0; + + // Merge regular documents with SurfSense docs + const documents = useMemo(() => { + // If filtering by type and not including SURFSENSE_DOCS, only show regular docs + if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) { + return regularDocuments; + } + // If filtering only by SURFSENSE_DOCS, only show surfsense docs + if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") { + return surfsenseDocsAsDocuments; + } + // Otherwise, merge both (surfsense docs first) + return [...surfsenseDocsAsDocuments, ...regularDocuments]; + }, [regularDocuments, surfsenseDocsAsDocuments, activeTypes]); + + const total = useMemo(() => { + if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) { + return regularTotal; + } + if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") { + return surfsenseDocsResponse?.total || 0; + } + return regularTotal + (surfsenseDocsResponse?.total || 0); + }, [regularTotal, surfsenseDocsResponse?.total, activeTypes]); + + const loading = useMemo(() => { + // If only SURFSENSE_DOCS selected, only check surfsense loading + if (onlySurfsenseDocsSelected) { + return isSurfsenseDocsLoading; + } + // Otherwise check both regular docs and surfsense docs loading + const regularLoading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading; + return regularLoading || (showSurfsenseDocs && isSurfsenseDocsLoading); + }, [ + onlySurfsenseDocsSelected, + isSurfsenseDocsLoading, + debouncedSearch, + isSearchLoading, + isDocumentsLoading, + showSurfsenseDocs, + ]); + + const error = useMemo(() => { + // If only SURFSENSE_DOCS selected, no regular docs errors + if (onlySurfsenseDocsSelected) { + return null; + } + return debouncedSearch.trim() ? searchError : documentsError; + }, [onlySurfsenseDocsSelected, debouncedSearch, searchError, documentsError]); // Display server-filtered results directly const displayDocs = documents || []; @@ -131,16 +234,24 @@ export default function DocumentsTable() { if (isRefreshing) return; setIsRefreshing(true); try { - if (debouncedSearch.trim()) { - await refetchSearch(); - } else { - await refetchDocuments(); + const refetchPromises: Promise[] = []; + // Only refetch regular documents if not in "only surfsense docs" mode + if (!onlySurfsenseDocsSelected) { + if (debouncedSearch.trim()) { + refetchPromises.push(refetchSearch()); + } else { + refetchPromises.push(refetchDocuments()); + } } + if (showSurfsenseDocs) { + refetchPromises.push(refetchSurfsenseDocs()); + } + await Promise.all(refetchPromises); toast.success(t("refresh_success") || "Documents refreshed"); } finally { setIsRefreshing(false); } - }, [debouncedSearch, refetchSearch, refetchDocuments, t, isRefreshing]); + }, [debouncedSearch, refetchSearch, refetchDocuments, refetchSurfsenseDocs, showSurfsenseDocs, onlySurfsenseDocsSelected, t, isRefreshing]); // Set up smart polling for active tasks - only polls when tasks are in progress const { summary } = useLogsSummary(searchSpaceId, 24, { diff --git a/surfsense_web/contracts/enums/connectorIcons.tsx b/surfsense_web/contracts/enums/connectorIcons.tsx index befe132f9..e2d667a16 100644 --- a/surfsense_web/contracts/enums/connectorIcons.tsx +++ b/surfsense_web/contracts/enums/connectorIcons.tsx @@ -1,5 +1,6 @@ import { IconLinkPlus, IconUsersGroup } from "@tabler/icons-react"; import { + BookOpen, File, FileText, Globe, @@ -86,6 +87,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas return ; case "EXTENSION": return ; + case "SURFSENSE_DOCS": + return ; case "DEEP": return ; case "DEEPER": diff --git a/surfsense_web/contracts/types/document.types.ts b/surfsense_web/contracts/types/document.types.ts index 757c6aeb4..c167d4cbf 100644 --- a/surfsense_web/contracts/types/document.types.ts +++ b/surfsense_web/contracts/types/document.types.ts @@ -22,6 +22,7 @@ export const documentTypeEnum = z.enum([ "LINEAR_CONNECTOR", "NOTE", "CIRCLEBACK", + "SURFSENSE_DOCS", ]); export const document = z.object({ @@ -183,6 +184,26 @@ export const getSurfsenseDocsByChunkRequest = z.object({ export const getSurfsenseDocsByChunkResponse = surfsenseDocsDocumentWithChunks; +/** + * List Surfsense docs + */ +export const getSurfsenseDocsRequest = z.object({ + page: z.number().optional(), + page_size: z.number().optional(), + title: z.string().optional(), +}); + +export const getSurfsenseDocsResponse = z.object({ + items: z.array(surfsenseDocsDocument.extend({ + created_at: z.string().nullable().optional(), + updated_at: z.string().nullable().optional(), + })), + total: z.number(), + page: z.number(), + page_size: z.number(), + has_more: z.boolean(), +}); + /** * Update document */ @@ -227,3 +248,5 @@ export type SurfsenseDocsDocument = z.infer; export type SurfsenseDocsDocumentWithChunks = z.infer; export type GetSurfsenseDocsByChunkRequest = z.infer; export type GetSurfsenseDocsByChunkResponse = z.infer; +export type GetSurfsenseDocsRequest = z.infer; +export type GetSurfsenseDocsResponse = z.infer; diff --git a/surfsense_web/lib/apis/documents-api.service.ts b/surfsense_web/lib/apis/documents-api.service.ts index 2e7d18e44..91ab154d6 100644 --- a/surfsense_web/lib/apis/documents-api.service.ts +++ b/surfsense_web/lib/apis/documents-api.service.ts @@ -9,6 +9,7 @@ import { type GetDocumentRequest, type GetDocumentsRequest, type GetDocumentTypeCountsRequest, + type GetSurfsenseDocsRequest, getDocumentByChunkRequest, getDocumentByChunkResponse, getDocumentRequest, @@ -18,6 +19,7 @@ import { getDocumentTypeCountsRequest, getDocumentTypeCountsResponse, getSurfsenseDocsByChunkResponse, + getSurfsenseDocsResponse, type SearchDocumentsRequest, searchDocumentsRequest, searchDocumentsResponse, @@ -221,6 +223,30 @@ class DocumentsApiService { ); }; + /** + * List all Surfsense documentation documents + */ + getSurfsenseDocs = async (request: GetSurfsenseDocsRequest = {}) => { + const queryParams = new URLSearchParams(); + + if (request.page !== undefined) { + queryParams.set("page", String(request.page)); + } + if (request.page_size !== undefined) { + queryParams.set("page_size", String(request.page_size)); + } + if (request.title) { + queryParams.set("title", request.title); + } + + const queryString = queryParams.toString(); + const url = queryString + ? `/api/v1/surfsense-docs?${queryString}` + : "/api/v1/surfsense-docs"; + + return baseApiService.get(url, getSurfsenseDocsResponse); + }; + /** * Update a document */ From ce62bef32204950710c0a39dc93152ebc72776ad Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 13 Jan 2026 02:35:00 +0200 Subject: [PATCH 03/18] i18n settings pages with clear titles --- .../[search_space_id]/settings/page.tsx | 68 ++++++++++--------- .../app/dashboard/user/settings/page.tsx | 38 ++++++----- surfsense_web/messages/en.json | 11 ++- surfsense_web/messages/zh.json | 11 ++- 4 files changed, 78 insertions(+), 50 deletions(-) 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 fde676dff..26cb882e2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx @@ -13,6 +13,7 @@ import { } 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 { LLMRoleManager } from "@/components/settings/llm-role-manager"; import { ModelConfigManager } from "@/components/settings/model-config-manager"; @@ -23,28 +24,28 @@ import { cn } from "@/lib/utils"; interface SettingsNavItem { id: string; - label: string; - description: string; + labelKey: string; + descriptionKey: string; icon: LucideIcon; } const settingsNavItems: SettingsNavItem[] = [ { id: "models", - label: "Agent Configs", - description: "LLM models with prompts & citations", + labelKey: "nav_agent_configs", + descriptionKey: "nav_agent_configs_desc", icon: Bot, }, { id: "roles", - label: "Role Assignments", - description: "Assign configs to agent roles", + labelKey: "nav_role_assignments", + descriptionKey: "nav_role_assignments_desc", icon: Brain, }, { id: "prompts", - label: "System Instructions", - description: "SearchSpace-wide AI instructions", + labelKey: "nav_system_instructions", + descriptionKey: "nav_system_instructions_desc", icon: MessageSquare, }, ]; @@ -62,6 +63,8 @@ function SettingsSidebar({ isOpen: boolean; onClose: () => void; }) { + const t = useTranslations("searchSpaceSettings"); + const handleNavClick = (sectionId: string) => { onSectionChange(sectionId); onClose(); // Close sidebar on mobile after selection @@ -94,22 +97,28 @@ function SettingsSidebar({ isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0" )} > - {/* Header with back button */} -
- - {/* Mobile close button */} - + {/* Header with title */} +
+
+ + {/* Mobile close button */} + +
+ {/* Settings Title */} +
+

{t("title")}

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

-

{item.description}

+

{t(item.descriptionKey)}

- {/* Footer */} -
-

Search Space Settings

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

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

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

{t("title")}

+
-
-

{t("footer")}

-
); diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 57f03a0fb..5ee25489d 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -105,7 +105,6 @@ "title": "User Settings", "description": "Manage your account settings and API access", "back_to_app": "Back to app", - "footer": "User Settings", "api_key_nav_label": "API Key", "api_key_nav_description": "Manage your API access token", "api_key_title": "API Key", @@ -684,6 +683,16 @@ "server_error": "Server error", "network_error": "Network error" }, + "searchSpaceSettings": { + "title": "Search Space Settings", + "back_to_app": "Back to app", + "nav_agent_configs": "Agent Configs", + "nav_agent_configs_desc": "LLM models with prompts & citations", + "nav_role_assignments": "Role Assignments", + "nav_role_assignments_desc": "Assign configs to agent roles", + "nav_system_instructions": "System Instructions", + "nav_system_instructions_desc": "SearchSpace-wide AI instructions" + }, "homepage": { "hero_title_part1": "The AI Workspace", "hero_title_part2": "Built for Teams", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 89cb7813a..8c1f53699 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -105,7 +105,6 @@ "title": "用户设置", "description": "管理您的账户设置和API访问", "back_to_app": "返回应用", - "footer": "用户设置", "api_key_nav_label": "API密钥", "api_key_nav_description": "管理您的API访问令牌", "api_key_title": "API密钥", @@ -678,6 +677,16 @@ "server_error": "服务器错误", "network_error": "网络错误" }, + "searchSpaceSettings": { + "title": "搜索空间设置", + "back_to_app": "返回应用", + "nav_agent_configs": "代理配置", + "nav_agent_configs_desc": "LLM 模型配置提示词和引用", + "nav_role_assignments": "角色分配", + "nav_role_assignments_desc": "为代理角色分配配置", + "nav_system_instructions": "系统指令", + "nav_system_instructions_desc": "搜索空间级别的 AI 指令" + }, "homepage": { "hero_title_part1": "AI 工作空间", "hero_title_part2": "为团队而生", From 7e3e784b4567b963ae777bdad21c927181247697 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 13 Jan 2026 02:42:59 +0200 Subject: [PATCH 04/18] add mentioned_surfsense_doc_ids to NewChatRequest schema --- surfsense_backend/app/schemas/new_chat.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index 78498cf04..62c61da6b 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -163,3 +163,6 @@ class NewChatRequest(BaseModel): mentioned_document_ids: list[int] | None = ( None # Optional document IDs mentioned with @ in the chat ) + mentioned_surfsense_doc_ids: list[int] | None = ( + None # Optional SurfSense documentation IDs mentioned with @ in the chat + ) From 5ecdfae8a9cb0feac92e6a472ad9bb7328e68cda Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 13 Jan 2026 02:50:56 +0200 Subject: [PATCH 05/18] add surfsense docs context formatting in stream_new_chat --- .../app/routes/new_chat_routes.py | 1 + .../app/tasks/chat/stream_new_chat.py | 61 +++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 476ff2935..da0d239d2 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -706,6 +706,7 @@ async def handle_new_chat( llm_config_id=llm_config_id, attachments=request.attachments, mentioned_document_ids=request.mentioned_document_ids, + mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids, ), media_type="text/event-stream", headers={ diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 3b87c33f1..25cec6959 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -25,7 +25,7 @@ from app.agents.new_chat.llm_config import ( load_agent_config, load_llm_config_from_yaml, ) -from app.db import Document +from app.db import Document, SurfsenseDocsDocument from app.schemas.new_chat import ChatAttachment from app.services.connector_service import ConnectorService from app.services.new_streaming_service import VercelStreamingService @@ -69,6 +69,29 @@ def format_mentioned_documents_as_context(documents: list[Document]) -> str: return "\n".join(context_parts) +def format_mentioned_surfsense_docs_as_context( + documents: list[SurfsenseDocsDocument], +) -> str: + """Format mentioned SurfSense documentation as context for the agent.""" + if not documents: + return "" + + 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." + ) + for i, doc in enumerate(documents, 1): + context_parts.append( + f"" + ) + context_parts.append(f"") + context_parts.append("") + context_parts.append("") + + return "\n".join(context_parts) + + def extract_todos_from_deepagents(command_output) -> dict: """ Extract todos from deepagents' TodoListMiddleware Command output. @@ -101,6 +124,7 @@ async def stream_new_chat( llm_config_id: int = -1, attachments: list[ChatAttachment] | None = None, mentioned_document_ids: list[int] | None = None, + mentioned_surfsense_doc_ids: list[int] | None = None, ) -> AsyncGenerator[str, None]: """ Stream chat responses from the new SurfSense deep agent. @@ -118,6 +142,7 @@ async def stream_new_chat( messages: Optional chat history from frontend (list of ChatMessage) attachments: Optional attachments with extracted content mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat + mentioned_surfsense_doc_ids: Optional list of SurfSense doc IDs mentioned with @ in the chat Yields: str: SSE formatted response strings @@ -208,7 +233,17 @@ async def stream_new_chat( ) mentioned_documents = list(result.scalars().all()) - # Format the user query with context (attachments + mentioned documents) + # Fetch mentioned SurfSense docs if any + mentioned_surfsense_docs: list[SurfsenseDocsDocument] = [] + if mentioned_surfsense_doc_ids: + result = await session.execute( + select(SurfsenseDocsDocument).filter( + SurfsenseDocsDocument.id.in_(mentioned_surfsense_doc_ids), + ) + ) + mentioned_surfsense_docs = list(result.scalars().all()) + + # Format the user query with context (attachments + mentioned documents + surfsense docs) final_query = user_query context_parts = [] @@ -220,6 +255,11 @@ async def stream_new_chat( format_mentioned_documents_as_context(mentioned_documents) ) + if mentioned_surfsense_docs: + context_parts.append( + format_mentioned_surfsense_docs_as_context(mentioned_surfsense_docs) + ) + if context_parts: context = "\n\n".join(context_parts) final_query = f"{context}\n\n{user_query}" @@ -296,13 +336,13 @@ async def stream_new_chat( last_active_step_id = analyze_step_id # Determine step title and action verb based on context - if attachments and mentioned_documents: + if attachments and (mentioned_documents or mentioned_surfsense_docs): last_active_step_title = "Analyzing your content" action_verb = "Reading" elif attachments: last_active_step_title = "Reading your content" action_verb = "Reading" - elif mentioned_documents: + elif mentioned_documents or mentioned_surfsense_docs: last_active_step_title = "Analyzing referenced content" action_verb = "Analyzing" else: @@ -342,6 +382,19 @@ async def stream_new_chat( else: processing_parts.append(f"[{len(doc_names)} documents]") + # Add mentioned SurfSense docs inline + if mentioned_surfsense_docs: + doc_names = [] + for doc in mentioned_surfsense_docs: + title = doc.title + if len(title) > 30: + title = title[:27] + "..." + doc_names.append(title) + if len(doc_names) == 1: + processing_parts.append(f"[📖 {doc_names[0]}]") + else: + processing_parts.append(f"[📖 {len(doc_names)} docs]") + last_active_step_items = [f"{action_verb}: {' '.join(processing_parts)}"] yield streaming_service.format_thinking_step( From 1b5f29afccc499ae990c49cb39a002c926c7babd Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 13 Jan 2026 02:52:56 +0200 Subject: [PATCH 06/18] add mentionedSurfsenseDoc atoms --- .../chat/mentioned-surfsense-docs.atom.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 surfsense_web/atoms/chat/mentioned-surfsense-docs.atom.ts diff --git a/surfsense_web/atoms/chat/mentioned-surfsense-docs.atom.ts b/surfsense_web/atoms/chat/mentioned-surfsense-docs.atom.ts new file mode 100644 index 000000000..aa4a84e05 --- /dev/null +++ b/surfsense_web/atoms/chat/mentioned-surfsense-docs.atom.ts @@ -0,0 +1,32 @@ +"use client"; + +import { atom } from "jotai"; +import type { SurfsenseDocsDocument } from "@/contracts/types/document.types"; + +/** + * Atom to store the IDs of SurfSense docs mentioned in the current chat composer. + * This is used to pass documentation context to the backend when sending a message. + */ +export const mentionedSurfsenseDocIdsAtom = atom([]); + +/** + * Atom to store the full SurfSense doc objects mentioned in the current chat composer. + * This persists across component remounts. + */ +export const mentionedSurfsenseDocsAtom = atom([]); + +/** + * Simplified SurfSense doc info for display purposes + */ +export interface MentionedSurfsenseDocInfo { + id: number; + title: string; + source: string; +} + +/** + * Atom to store mentioned SurfSense docs per message ID. + * This allows displaying which docs were mentioned with each user message. + */ +export const messageSurfsenseDocsMapAtom = atom>({}); + From cd3677b5fab78aad105ab22135c7c1721f05b0df Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 13 Jan 2026 06:14:58 +0200 Subject: [PATCH 07/18] feat: add SurfSense docs mention support in chat --- .../app/tasks/chat/stream_new_chat.py | 42 +++++++++-- .../new-chat/[[...chat_id]]/page.tsx | 22 ++++-- .../atoms/chat/mentioned-documents.atom.ts | 12 +++- .../chat/mentioned-surfsense-docs.atom.ts | 32 --------- .../components/assistant-ui/composer.tsx | 49 ++++++++----- .../assistant-ui/inline-mention-editor.tsx | 43 +++++++---- .../components/assistant-ui/thread.tsx | 51 ++++++++----- .../components/assistant-ui/user-message.tsx | 2 +- .../new-chat/document-mention-picker.tsx | 71 ++++++++++++++----- .../contracts/types/document.types.ts | 9 +-- .../lib/apis/documents-api.service.ts | 34 +++++---- 11 files changed, 226 insertions(+), 141 deletions(-) delete mode 100644 surfsense_web/atoms/chat/mentioned-surfsense-docs.atom.ts diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 25cec6959..45899c2ef 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -76,17 +76,42 @@ def format_mentioned_surfsense_docs_as_context( if not documents: return "" + import json + context_parts = [""] context_parts.append( "The user has explicitly mentioned the following SurfSense documentation pages. " "These are official documentation about how to use SurfSense and should be used to answer questions about the application." ) - for i, doc in enumerate(documents, 1): - context_parts.append( - f"" - ) - context_parts.append(f"") - context_parts.append("") + + for doc in documents: + metadata_json = json.dumps({"source": doc.source}, ensure_ascii=False) + + context_parts.append("") + context_parts.append("") + context_parts.append(f" doc-{doc.id}") + context_parts.append(" SURFSENSE_DOCS") + context_parts.append(f" <![CDATA[{doc.title}]]>") + context_parts.append(f" ") + context_parts.append(f" ") + context_parts.append("") + context_parts.append("") + context_parts.append("") + + if hasattr(doc, 'chunks') and doc.chunks: + for chunk in doc.chunks: + context_parts.append( + f" " + ) + else: + context_parts.append( + f" " + ) + + context_parts.append("") + context_parts.append("") + context_parts.append("") + context_parts.append("") return "\n".join(context_parts) @@ -236,8 +261,11 @@ async def stream_new_chat( # Fetch mentioned SurfSense docs if any mentioned_surfsense_docs: list[SurfsenseDocsDocument] = [] if mentioned_surfsense_doc_ids: + from sqlalchemy.orm import selectinload result = await session.execute( - select(SurfsenseDocsDocument).filter( + select(SurfsenseDocsDocument) + .options(selectinload(SurfsenseDocsDocument.chunks)) + .filter( SurfsenseDocsDocument.id.in_(mentioned_surfsense_doc_ids), ) ) 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 b1abd647f..489e17b20 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 @@ -265,7 +265,10 @@ export default function NewChatPage() { setMessages([]); setThreadId(null); setMessageThinkingSteps(new Map()); - setMentionedDocumentIds([]); + setMentionedDocumentIds({ + surfsense_doc_ids: [], + document_ids: [], + }); setMentionedDocuments([]); setMessageDocumentsMap({}); clearPlanOwnerRegistry(); // Reset plan ownership for new chat @@ -429,7 +432,7 @@ export default function NewChatPage() { // Track message sent trackChatMessageSent(searchSpaceId, currentThreadId, { hasAttachments: messageAttachments.length > 0, - hasMentionedDocuments: mentionedDocumentIds.length > 0, + hasMentionedDocuments: mentionedDocumentIds.surfsense_doc_ids.length > 0 || mentionedDocumentIds.document_ids.length > 0, messageLength: userQuery.length, }); @@ -627,12 +630,16 @@ export default function NewChatPage() { // Extract attachment content to send with the request const attachments = extractAttachmentContent(messageAttachments); - // Get mentioned document IDs for context - const documentIds = mentionedDocumentIds.length > 0 ? [...mentionedDocumentIds] : undefined; + // Get mentioned document IDs for context (separate fields for backend) + const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0; + const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0; // Clear mentioned documents after capturing them - if (mentionedDocumentIds.length > 0) { - setMentionedDocumentIds([]); + if (hasDocumentIds || hasSurfsenseDocIds) { + setMentionedDocumentIds({ + surfsense_doc_ids: [], + document_ids: [], + }); setMentionedDocuments([]); } @@ -648,7 +655,8 @@ export default function NewChatPage() { search_space_id: searchSpaceId, messages: messageHistory, attachments: attachments.length > 0 ? attachments : undefined, - mentioned_document_ids: documentIds, + mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_ids : undefined, + mentioned_surfsense_doc_ids: hasSurfsenseDocIds ? mentionedDocumentIds.surfsense_doc_ids : undefined, }), signal: controller.signal, }); diff --git a/surfsense_web/atoms/chat/mentioned-documents.atom.ts b/surfsense_web/atoms/chat/mentioned-documents.atom.ts index 79ea27d12..17ae38616 100644 --- a/surfsense_web/atoms/chat/mentioned-documents.atom.ts +++ b/surfsense_web/atoms/chat/mentioned-documents.atom.ts @@ -1,19 +1,25 @@ "use client"; import { atom } from "jotai"; -import type { Document } from "@/contracts/types/document.types"; +import type { Document, SurfsenseDocsDocument } from "@/contracts/types/document.types"; /** * Atom to store the IDs of documents mentioned in the current chat composer. * This is used to pass document context to the backend when sending a message. */ -export const mentionedDocumentIdsAtom = atom([]); +export const mentionedDocumentIdsAtom = atom<{ + surfsense_doc_ids: number[]; + document_ids: number[]; +}>({ + surfsense_doc_ids: [], + document_ids: [], +}); /** * Atom to store the full document objects mentioned in the current chat composer. * This persists across component remounts. */ -export const mentionedDocumentsAtom = atom([]); +export const mentionedDocumentsAtom = atom<(Pick)[]>([]); /** * Simplified document info for display purposes diff --git a/surfsense_web/atoms/chat/mentioned-surfsense-docs.atom.ts b/surfsense_web/atoms/chat/mentioned-surfsense-docs.atom.ts deleted file mode 100644 index aa4a84e05..000000000 --- a/surfsense_web/atoms/chat/mentioned-surfsense-docs.atom.ts +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import { atom } from "jotai"; -import type { SurfsenseDocsDocument } from "@/contracts/types/document.types"; - -/** - * Atom to store the IDs of SurfSense docs mentioned in the current chat composer. - * This is used to pass documentation context to the backend when sending a message. - */ -export const mentionedSurfsenseDocIdsAtom = atom([]); - -/** - * Atom to store the full SurfSense doc objects mentioned in the current chat composer. - * This persists across component remounts. - */ -export const mentionedSurfsenseDocsAtom = atom([]); - -/** - * Simplified SurfSense doc info for display purposes - */ -export interface MentionedSurfsenseDocInfo { - id: number; - title: string; - source: string; -} - -/** - * Atom to store mentioned SurfSense docs per message ID. - * This allows displaying which docs were mentioned with each user message. - */ -export const messageSurfsenseDocsMapAtom = atom>({}); - diff --git a/surfsense_web/components/assistant-ui/composer.tsx b/surfsense_web/components/assistant-ui/composer.tsx index 8f8ee5e0b..417f7c70f 100644 --- a/surfsense_web/components/assistant-ui/composer.tsx +++ b/surfsense_web/components/assistant-ui/composer.tsx @@ -53,7 +53,10 @@ export const Composer: FC = () => { // Sync mentioned document IDs to atom for use in chat request useEffect(() => { - setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id)); + setMentionedDocumentIds({ + surfsense_doc_ids: mentionedDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), + document_ids: mentionedDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), + }); }, [mentionedDocuments, setMentionedDocumentIds]); // Handle text change from inline editor - sync with assistant-ui composer @@ -119,7 +122,10 @@ export const Composer: FC = () => { // Clear the editor after sending editorRef.current?.clear(); setMentionedDocuments([]); - setMentionedDocumentIds([]); + setMentionedDocumentIds({ + surfsense_doc_ids: [], + document_ids: [], + }); } }, [ showDocumentPopover, @@ -129,41 +135,48 @@ export const Composer: FC = () => { setMentionedDocumentIds, ]); - // Handle document removal from inline editor const handleDocumentRemove = useCallback( - (docId: number) => { + (docId: number, docType?: string) => { setMentionedDocuments((prev) => { - const updated = prev.filter((doc) => doc.id !== docId); - // Immediately sync document IDs to avoid race conditions - setMentionedDocumentIds(updated.map((doc) => doc.id)); + const updated = prev.filter( + (doc) => !(doc.id === docId && doc.document_type === docType) + ); + setMentionedDocumentIds({ + surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), + document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), + }); return updated; }); }, [setMentionedDocuments, setMentionedDocumentIds] ); - // Handle document selection from picker const handleDocumentsMention = useCallback( - (documents: Document[]) => { - // Insert chips into the inline editor for each new document - const existingIds = new Set(mentionedDocuments.map((d) => d.id)); - const newDocs = documents.filter((doc) => !existingIds.has(doc.id)); + (documents: Pick[]) => { + const existingKeys = new Set( + mentionedDocuments.map((d) => `${d.document_type}:${d.id}`) + ); + const newDocs = documents.filter( + (doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`) + ); for (const doc of newDocs) { editorRef.current?.insertDocumentChip(doc); } - // Update mentioned documents state setMentionedDocuments((prev) => { - const existingIdSet = new Set(prev.map((d) => d.id)); - const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id)); + const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`)); + const uniqueNewDocs = documents.filter( + (doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`) + ); const updated = [...prev, ...uniqueNewDocs]; - // Immediately sync document IDs to avoid race conditions - setMentionedDocumentIds(updated.map((doc) => doc.id)); + setMentionedDocumentIds({ + surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), + document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), + }); return updated; }); - // Reset mention query but keep popover open for more selections setMentionQuery(""); }, [mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds] diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 12a8f895f..4fa847a95 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -25,7 +25,7 @@ export interface InlineMentionEditorRef { clear: () => void; getText: () => string; getMentionedDocuments: () => MentionedDocument[]; - insertDocumentChip: (doc: Document) => void; + insertDocumentChip: (doc: Pick) => void; } interface InlineMentionEditorProps { @@ -34,7 +34,7 @@ interface InlineMentionEditorProps { onMentionClose?: () => void; onSubmit?: () => void; onChange?: (text: string, docs: MentionedDocument[]) => void; - onDocumentRemove?: (docId: number) => void; + onDocumentRemove?: (docId: number, docType?: string) => void; onKeyDown?: (e: React.KeyboardEvent) => void; disabled?: boolean; className?: string; @@ -44,6 +44,7 @@ interface InlineMentionEditorProps { // Unique data attribute to identify chip elements const CHIP_DATA_ATTR = "data-mention-chip"; const CHIP_ID_ATTR = "data-mention-id"; +const CHIP_DOCTYPE_ATTR = "data-mention-doctype"; /** * Type guard to check if a node is a chip element @@ -66,6 +67,13 @@ function getChipId(element: Element): number | null { return Number.isNaN(id) ? null : id; } +/** + * Get chip document type from element attribute + */ +function getChipDocType(element: Element): string { + return element.getAttribute(CHIP_DOCTYPE_ATTR) ?? "UNKNOWN"; +} + export const InlineMentionEditor = forwardRef( ( { @@ -84,15 +92,15 @@ export const InlineMentionEditor = forwardRef { const editorRef = useRef(null); const [isEmpty, setIsEmpty] = useState(true); - const [mentionedDocs, setMentionedDocs] = useState>( - () => new Map(initialDocuments.map((d) => [d.id, d])) + const [mentionedDocs, setMentionedDocs] = useState>( + () => new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d])) ); const isComposingRef = useRef(false); // Sync initial documents useEffect(() => { if (initialDocuments.length > 0) { - setMentionedDocs(new Map(initialDocuments.map((d) => [d.id, d]))); + setMentionedDocs(new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))); } }, [initialDocuments]); @@ -153,6 +161,7 @@ export const InlineMentionEditor = forwardRef { const next = new Map(prev); - next.delete(doc.id); + next.delete(docKey); return next; }); // Notify parent that a document was removed - onDocumentRemove?.(doc.id); + onDocumentRemove?.(doc.id, doc.document_type); focusAtEnd(); }; @@ -195,7 +205,7 @@ export const InlineMentionEditor = forwardRef { + (doc: Pick) => { if (!editorRef.current) return; // Validate required fields for type safety @@ -210,8 +220,9 @@ export const InlineMentionEditor = forwardRef new Map(prev).set(doc.id, mentionDoc)); + // Add to mentioned docs map using unique key + const docKey = `${doc.document_type ?? "UNKNOWN"}:${doc.id}`; + setMentionedDocs((prev) => new Map(prev).set(docKey, mentionDoc)); // Find and remove the @query text const selection = window.getSelection(); @@ -413,15 +424,17 @@ export const InlineMentionEditor = forwardRef { const next = new Map(prev); - next.delete(chipId); + next.delete(chipKey); return next; }); // Notify parent that a document was removed - onDocumentRemove?.(chipId); + onDocumentRemove?.(chipId, chipDocType); } return; } @@ -448,15 +461,17 @@ export const InlineMentionEditor = forwardRef { const next = new Map(prev); - next.delete(chipId); + next.delete(chipKey); return next; }); // Notify parent that a document was removed - onDocumentRemove?.(chipId); + onDocumentRemove?.(chipId, chipDocType); } } } diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 6dc13fddf..92b8ad786 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -229,7 +229,10 @@ const Composer: FC = () => { // Sync mentioned document IDs to atom for use in chat request useEffect(() => { - setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id)); + setMentionedDocumentIds({ + surfsense_doc_ids: mentionedDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), + document_ids: mentionedDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), + }); }, [mentionedDocuments, setMentionedDocumentIds]); // Handle text change from inline editor - sync with assistant-ui composer @@ -295,7 +298,10 @@ const Composer: FC = () => { // Clear the editor after sending editorRef.current?.clear(); setMentionedDocuments([]); - setMentionedDocumentIds([]); + setMentionedDocumentIds({ + surfsense_doc_ids: [], + document_ids: [], + }); } }, [ showDocumentPopover, @@ -305,41 +311,48 @@ const Composer: FC = () => { setMentionedDocumentIds, ]); - // Handle document removal from inline editor const handleDocumentRemove = useCallback( - (docId: number) => { + (docId: number, docType?: string) => { setMentionedDocuments((prev) => { - const updated = prev.filter((doc) => doc.id !== docId); - // Immediately sync document IDs to avoid race conditions - setMentionedDocumentIds(updated.map((doc) => doc.id)); + const updated = prev.filter( + (doc) => !(doc.id === docId && doc.document_type === docType) + ); + setMentionedDocumentIds({ + surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), + document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), + }); return updated; }); }, [setMentionedDocuments, setMentionedDocumentIds] ); - // Handle document selection from picker const handleDocumentsMention = useCallback( - (documents: Document[]) => { - // Insert chips into the inline editor for each new document - const existingIds = new Set(mentionedDocuments.map((d) => d.id)); - const newDocs = documents.filter((doc) => !existingIds.has(doc.id)); + (documents: Pick[]) => { + const existingKeys = new Set( + mentionedDocuments.map((d) => `${d.document_type}:${d.id}`) + ); + const newDocs = documents.filter( + (doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`) + ); for (const doc of newDocs) { editorRef.current?.insertDocumentChip(doc); } - // Update mentioned documents state setMentionedDocuments((prev) => { - const existingIdSet = new Set(prev.map((d) => d.id)); - const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id)); + const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`)); + const uniqueNewDocs = documents.filter( + (doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`) + ); const updated = [...prev, ...uniqueNewDocs]; - // Immediately sync document IDs to avoid race conditions - setMentionedDocumentIds(updated.map((doc) => doc.id)); + setMentionedDocumentIds({ + surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), + document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), + }); return updated; }); - // Reset mention query but keep popover open for more selections setMentionQuery(""); }, [mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds] @@ -640,7 +653,7 @@ const UserMessage: FC = () => { {/* Mentioned documents as chips */} {mentionedDocs?.map((doc) => ( diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index dcf626461..745542304 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -29,7 +29,7 @@ export const UserMessage: FC = () => { {/* Mentioned documents as chips */} {mentionedDocs?.map((doc) => ( diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index 7a9e7aaa5..90515d52d 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -25,9 +25,9 @@ export interface DocumentMentionPickerRef { interface DocumentMentionPickerProps { searchSpaceId: number; - onSelectionChange: (documents: Document[]) => void; + onSelectionChange: (documents: Pick[]) => void; onDone: () => void; - initialSelectedDocuments?: Document[]; + initialSelectedDocuments?: Pick[]; externalSearch?: string; } @@ -57,7 +57,7 @@ export const DocumentMentionPicker = forwardRef< const scrollContainerRef = useRef(null); // State for pagination - const [accumulatedDocuments, setAccumulatedDocuments] = useState([]); + const [accumulatedDocuments, setAccumulatedDocuments] = useState[]>([]); const [currentPage, setCurrentPage] = useState(0); const [hasMore, setHasMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); @@ -90,6 +90,13 @@ export const DocumentMentionPicker = forwardRef< }; }, [debouncedSearch, searchSpaceId]); + const surfsenseDocsQueryParams = useMemo(() => { + return { + page: 0, + page_size: PAGE_SIZE, + }; + }, []); + // Use query for fetching first page of documents const { data: documents, isLoading: isDocumentsLoading } = useQuery({ queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams), @@ -106,22 +113,45 @@ export const DocumentMentionPicker = forwardRef< enabled: !!searchSpaceId && !!debouncedSearch.trim() && currentPage === 0, }); - // Update accumulated documents when first page loads + // Use query for fetching first page of SurfSense docs + const { data: surfsenseDocs, isLoading: isSurfsenseDocsLoading } = useQuery({ + queryKey: ["surfsense-docs-mention", surfsenseDocsQueryParams], + queryFn: () => documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }), + staleTime: 3 * 60 * 1000, + }); + + // Update accumulated documents when first page loads - combine both sources useEffect(() => { if (currentPage === 0) { + const combinedDocs: Pick[] = []; + + // Add SurfSense docs first (they appear at top) + if (surfsenseDocs?.items) { + for (const doc of surfsenseDocs.items) { + combinedDocs.push({ + id: doc.id, + title: doc.title, + document_type: "SURFSENSE_DOCS", + }); + } + } + + // Add regular documents if (debouncedSearch.trim()) { - if (searchedDocuments) { - setAccumulatedDocuments(searchedDocuments.items); + if (searchedDocuments?.items) { + combinedDocs.push(...searchedDocuments.items); setHasMore(searchedDocuments.has_more); } } else { - if (documents) { - setAccumulatedDocuments(documents.items); + if (documents?.items) { + combinedDocs.push(...documents.items); setHasMore(documents.has_more); } } + + setAccumulatedDocuments(combinedDocs); } - }, [documents, searchedDocuments, debouncedSearch, currentPage]); + }, [documents, searchedDocuments, surfsenseDocs, debouncedSearch, currentPage]); // Function to load next page const loadNextPage = useCallback(async () => { @@ -175,22 +205,22 @@ export const DocumentMentionPicker = forwardRef< const actualDocuments = accumulatedDocuments; const actualLoading = - (debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) && currentPage === 0; + ((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) || isSurfsenseDocsLoading) && currentPage === 0; - // Track already selected document IDs - const selectedIds = useMemo( - () => new Set(initialSelectedDocuments.map((d) => d.id)), + // Track already selected documents using unique key (document_type:id) to avoid ID collisions + const selectedKeys = useMemo( + () => new Set(initialSelectedDocuments.map((d) => `${d.document_type}:${d.id}`)), [initialSelectedDocuments] ); // Filter out already selected documents for navigation const selectableDocuments = useMemo( - () => actualDocuments.filter((doc) => !selectedIds.has(doc.id)), - [actualDocuments, selectedIds] + () => actualDocuments.filter((doc) => !selectedKeys.has(`${doc.document_type}:${doc.id}`)), + [actualDocuments, selectedKeys] ); const handleSelectDocument = useCallback( - (doc: Document) => { + (doc: Pick) => { onSelectionChange([...initialSelectedDocuments, doc]); onDone(); }, @@ -287,13 +317,16 @@ export const DocumentMentionPicker = forwardRef< ) : (
{actualDocuments.map((doc) => { - const isAlreadySelected = selectedIds.has(doc.id); - const selectableIndex = selectableDocuments.findIndex((d) => d.id === doc.id); + const docKey = `${doc.document_type}:${doc.id}`; + const isAlreadySelected = selectedKeys.has(docKey); + const selectableIndex = selectableDocuments.findIndex( + (d) => d.document_type === doc.document_type && d.id === doc.id + ); const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; return (
- +
+ + +
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": "暂无对话", From c22e7ce56c20f2172475824b13502968f8a789a6 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 13 Jan 2026 00:24:17 -0800 Subject: [PATCH 12/18] feat: cleanup --- surfsense_web/components/layout/index.ts | 2 - .../layout/providers/LayoutDataProvider.tsx | 2 +- .../components/layout/types/layout.types.ts | 14 +- surfsense_web/components/layout/ui/index.ts | 1 - .../layout/ui/sidebar/AllChatsSidebar.tsx | 488 ------------------ .../layout/ui/sidebar/AllNotesSidebar.tsx | 407 --------------- .../layout/ui/sidebar/NoteListItem.tsx | 76 --- .../components/layout/ui/sidebar/index.ts | 2 - surfsense_web/messages/en.json | 21 +- surfsense_web/messages/zh.json | 27 +- 10 files changed, 13 insertions(+), 1027 deletions(-) delete mode 100644 surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx delete mode 100644 surfsense_web/components/layout/ui/sidebar/AllNotesSidebar.tsx delete mode 100644 surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx diff --git a/surfsense_web/components/layout/index.ts b/surfsense_web/components/layout/index.ts index 18f8cc9d3..57b675088 100644 --- a/surfsense_web/components/layout/index.ts +++ b/surfsense_web/components/layout/index.ts @@ -4,7 +4,6 @@ export type { ChatItem, IconRailProps, NavItem, - NoteItem, PageUsage, SearchSpace, SidebarSectionProps, @@ -21,7 +20,6 @@ export { MobileSidebarTrigger, NavIcon, NavSection, - NoteListItem, PageUsageDisplay, SearchSpaceAvatar, Sidebar, diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 135a3d445..7b4159b13 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useAtomValue, useSetAtom } from "jotai"; +import { useAtomValue } from "jotai"; import { Logs, SquareLibrary, Trash2 } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; diff --git a/surfsense_web/components/layout/types/layout.types.ts b/surfsense_web/components/layout/types/layout.types.ts index f8d06ab73..51b06daa3 100644 --- a/surfsense_web/components/layout/types/layout.types.ts +++ b/surfsense_web/components/layout/types/layout.types.ts @@ -31,14 +31,6 @@ export interface ChatItem { isOwnThread?: boolean; } -export interface NoteItem { - id: number; - name: string; - url: string; - isActive?: boolean; - isReindexing?: boolean; -} - export interface PageUsage { pagesUsed: number; pagesLimit: number; @@ -74,7 +66,8 @@ export interface ChatsSectionProps { activeChatId?: number | null; onChatSelect: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; - onViewAllChats?: () => void; + onViewAllSharedChats?: () => void; + onViewAllPrivateChats?: () => void; searchSpaceId?: string; } @@ -104,7 +97,8 @@ export interface SidebarProps { onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; - onViewAllChats?: () => void; + onViewAllSharedChats?: () => void; + onViewAllPrivateChats?: () => void; user: User; theme?: string; onSettings?: () => void; diff --git a/surfsense_web/components/layout/ui/index.ts b/surfsense_web/components/layout/ui/index.ts index bd3d54838..875e3b746 100644 --- a/surfsense_web/components/layout/ui/index.ts +++ b/surfsense_web/components/layout/ui/index.ts @@ -8,7 +8,6 @@ export { MobileSidebar, MobileSidebarTrigger, NavSection, - NoteListItem, PageUsageDisplay, Sidebar, SidebarCollapseButton, diff --git a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx deleted file mode 100644 index 4b85808e5..000000000 --- a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx +++ /dev/null @@ -1,488 +0,0 @@ -"use client"; - -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, 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"; - -type TabType = "shared" | "private"; - -interface AllChatsSidebarProps { - open: boolean; - onOpenChange: (open: boolean) => void; - searchSpaceId: string; - onCloseMobileSidebar?: () => void; -} - -export function AllChatsSidebar({ - open, - onOpenChange, - searchSpaceId, - onCloseMobileSidebar, -}: AllChatsSidebarProps) { - const t = useTranslations("sidebar"); - const router = useRouter(); - const params = useParams(); - const queryClient = useQueryClient(); - - // Get the current chat ID from URL to check if user is deleting the currently open chat - const currentChatId = Array.isArray(params.chat_id) - ? Number(params.chat_id[0]) - : params.chat_id - ? Number(params.chat_id) - : null; - const [deletingThreadId, setDeletingThreadId] = useState(null); - const [archivingThreadId, setArchivingThreadId] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const [activeTab, setActiveTab] = useState("shared"); - const [mounted, setMounted] = useState(false); - const [openDropdownId, setOpenDropdownId] = useState(null); - const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); - - const isSearchMode = !!debouncedSearchQuery.trim(); - - // Handle mounting for portal - useEffect(() => { - setMounted(true); - }, []); - - // Handle escape key - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape" && open) { - onOpenChange(false); - } - }; - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - }, [open, onOpenChange]); - - // Lock body scroll when open - useEffect(() => { - if (open) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = ""; - } - return () => { - document.body.style.overflow = ""; - }; - }, [open]); - - // Fetch all threads (when not searching) - const { - data: threadsData, - error: threadsError, - isLoading: isLoadingThreads, - } = useQuery({ - queryKey: ["all-threads", searchSpaceId], - queryFn: () => fetchThreads(Number(searchSpaceId)), - enabled: !!searchSpaceId && open && !isSearchMode, - }); - - // Search threads (when searching) - const { - data: searchData, - error: searchError, - isLoading: isLoadingSearch, - } = useQuery({ - queryKey: ["search-threads", searchSpaceId, debouncedSearchQuery], - queryFn: () => searchThreads(Number(searchSpaceId), debouncedSearchQuery.trim()), - 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) => { - router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`); - onOpenChange(false); - // Also close the main sidebar on mobile - onCloseMobileSidebar?.(); - }, - [router, onOpenChange, searchSpaceId, onCloseMobileSidebar] - ); - - // Handle thread deletion - const handleDeleteThread = useCallback( - async (threadId: number) => { - setDeletingThreadId(threadId); - 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 the deleted chat is currently open, close sidebar first then redirect - if (currentChatId === threadId) { - onOpenChange(false); - // Wait for sidebar close animation to complete before navigating - setTimeout(() => { - router.push(`/dashboard/${searchSpaceId}/new-chat`); - }, 250); - } - } 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] - ); - - // Handle thread archive/unarchive - 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] - ); - - // Clear search - const handleClearSearch = useCallback(() => { - setSearchQuery(""); - }, []); - - const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads; - const error = isSearchMode ? searchError : threadsError; - - // Get counts for tabs - const sharedCount = sharedChats.length; - const privateCount = privateChats.length; - - if (!mounted) return null; - - return createPortal( - - {open && ( - <> - {/* Backdrop */} - onOpenChange(false)} - aria-hidden="true" - /> - - {/* Panel */} - - {/* Header */} -
-
-

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

- -
- - {/* Search Input */} -
- - setSearchQuery(e.target.value)} - className="pl-9 pr-8 h-9" - /> - {searchQuery && ( - - )} -
-
- - {/* Tab toggle for shared/private chats */} -
- - -
- - {/* Scrollable Content */} -
- {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; - const isShared = thread.visibility === "SEARCH_SPACE"; - - return ( -
- {/* Main clickable area for navigation */} - - - - - -
-

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

- {thread.archived && ( -

Archived

- )} -
-
-
- - {/* Actions dropdown */} - 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"} -

-
- ) : ( -
- {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"} -

- - )} -
- )} -
-
- - )} -
, - document.body - ); -} diff --git a/surfsense_web/components/layout/ui/sidebar/AllNotesSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllNotesSidebar.tsx deleted file mode 100644 index 67d1b4ba6..000000000 --- a/surfsense_web/components/layout/ui/sidebar/AllNotesSidebar.tsx +++ /dev/null @@ -1,407 +0,0 @@ -"use client"; - -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { format } from "date-fns"; -import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useParams, useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { createPortal } from "react-dom"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useDebouncedValue } from "@/hooks/use-debounced-value"; -import { documentsApiService } from "@/lib/apis/documents-api.service"; -import { notesApiService } from "@/lib/apis/notes-api.service"; -import { cn } from "@/lib/utils"; - -interface AllNotesSidebarProps { - open: boolean; - onOpenChange: (open: boolean) => void; - searchSpaceId: string; - onAddNote?: () => void; - onCloseMobileSidebar?: () => void; -} - -export function AllNotesSidebar({ - open, - onOpenChange, - searchSpaceId, - onAddNote, - onCloseMobileSidebar, -}: AllNotesSidebarProps) { - const t = useTranslations("sidebar"); - const router = useRouter(); - const params = useParams(); - const queryClient = useQueryClient(); - - // Get the current note ID from URL to highlight the open note - const currentNoteId = params.note_id ? Number(params.note_id) : null; - const [deletingNoteId, setDeletingNoteId] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const [mounted, setMounted] = useState(false); - const [openDropdownId, setOpenDropdownId] = useState(null); - const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); - - // Handle mounting for portal - useEffect(() => { - setMounted(true); - }, []); - - // Handle escape key - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape" && open) { - onOpenChange(false); - } - }; - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - }, [open, onOpenChange]); - - // Lock body scroll when open - useEffect(() => { - if (open) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = ""; - } - return () => { - document.body.style.overflow = ""; - }; - }, [open]); - - // Fetch all notes (when no search query) - const { - data: notesData, - error: notesError, - isLoading: isLoadingNotes, - } = useQuery({ - queryKey: ["all-notes", searchSpaceId], - queryFn: () => - notesApiService.getNotes({ - search_space_id: Number(searchSpaceId), - page_size: 1000, - }), - enabled: !!searchSpaceId && open && !debouncedSearchQuery, - }); - - // Search notes (when there's a search query) - const { - data: searchData, - error: searchError, - isLoading: isSearching, - } = useQuery({ - queryKey: ["search-notes", searchSpaceId, debouncedSearchQuery], - queryFn: () => - documentsApiService.searchDocuments({ - queryParams: { - search_space_id: Number(searchSpaceId), - document_types: ["NOTE"], - title: debouncedSearchQuery, - page_size: 100, - }, - }), - enabled: !!searchSpaceId && open && !!debouncedSearchQuery, - }); - - // Handle note navigation - const handleNoteClick = useCallback( - (noteId: number, noteSearchSpaceId: number) => { - router.push(`/dashboard/${noteSearchSpaceId}/editor/${noteId}`); - onOpenChange(false); - // Also close the main sidebar on mobile - onCloseMobileSidebar?.(); - }, - [router, onOpenChange, onCloseMobileSidebar] - ); - - // Handle note deletion - const handleDeleteNote = useCallback( - async (noteId: number, noteSearchSpaceId: number) => { - setDeletingNoteId(noteId); - try { - await notesApiService.deleteNote({ - search_space_id: noteSearchSpaceId, - note_id: noteId, - }); - queryClient.invalidateQueries({ queryKey: ["all-notes", searchSpaceId] }); - queryClient.invalidateQueries({ queryKey: ["notes", searchSpaceId] }); - queryClient.invalidateQueries({ queryKey: ["search-notes", searchSpaceId] }); - } catch (error) { - console.error("Error deleting note:", error); - } finally { - setDeletingNoteId(null); - } - }, - [queryClient, searchSpaceId] - ); - - // Clear search - const handleClearSearch = useCallback(() => { - setSearchQuery(""); - }, []); - - // Determine which data to show - const isSearchMode = !!debouncedSearchQuery; - const isLoading = isSearchMode ? isSearching : isLoadingNotes; - const error = isSearchMode ? searchError : notesError; - - // Transform and sort notes data - handle both regular notes and search results - const notes = useMemo(() => { - let notesList: { - id: number; - title: string; - search_space_id: number; - created_at: string; - updated_at?: string | null; - }[]; - - if (isSearchMode && searchData?.items) { - notesList = searchData.items.map((doc) => ({ - id: doc.id, - title: doc.title, - search_space_id: doc.search_space_id, - created_at: doc.created_at, - updated_at: doc.updated_at, - })); - } else { - notesList = notesData?.items ?? []; - } - - // Sort notes by updated_at (most recent first), fallback to created_at - return [...notesList].sort((a, b) => { - const dateA = a.updated_at - ? new Date(a.updated_at).getTime() - : new Date(a.created_at).getTime(); - const dateB = b.updated_at - ? new Date(b.updated_at).getTime() - : new Date(b.created_at).getTime(); - return dateB - dateA; // Descending order (most recent first) - }); - }, [isSearchMode, searchData, notesData]); - - if (!mounted) return null; - - return createPortal( - - {open && ( - <> - {/* Backdrop */} - onOpenChange(false)} - aria-hidden="true" - /> - - {/* Panel */} - - {/* Header */} -
-
-

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

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

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

- {note.updated_at && ( -

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

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

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

-

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

-
- ) : ( -
- -

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

- {onAddNote && ( - - )} -
- )} -
- - {/* Footer with Add Note button */} - {onAddNote && notes.length > 0 && ( -
- -
- )} -
- - )} -
, - document.body - ); -} diff --git a/surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx b/surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx deleted file mode 100644 index 0491ebcca..000000000 --- a/surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client"; - -import { FileText, Loader2, MoreHorizontal } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { cn } from "@/lib/utils"; - -interface NoteListItemProps { - name: string; - isActive?: boolean; - isReindexing?: boolean; - onClick?: () => void; - onDelete?: () => void; -} - -export function NoteListItem({ - name, - isActive, - isReindexing, - onClick, - onDelete, -}: NoteListItemProps) { - const t = useTranslations("sidebar"); - - return ( -
- - - {/* Actions dropdown */} -
- - - - - - { - e.stopPropagation(); - onDelete?.(); - }} - className="text-destructive focus:text-destructive" - > - {t("delete")} - - - -
-
- ); -} diff --git a/surfsense_web/components/layout/ui/sidebar/index.ts b/surfsense_web/components/layout/ui/sidebar/index.ts index 89d542629..282e4740b 100644 --- a/surfsense_web/components/layout/ui/sidebar/index.ts +++ b/surfsense_web/components/layout/ui/sidebar/index.ts @@ -1,10 +1,8 @@ -export { AllChatsSidebar } from "./AllChatsSidebar"; export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar"; export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar"; export { ChatListItem } from "./ChatListItem"; export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; export { NavSection } from "./NavSection"; -export { NoteListItem } from "./NoteListItem"; export { PageUsageDisplay } from "./PageUsageDisplay"; export { Sidebar } from "./Sidebar"; export { SidebarCollapseButton } from "./SidebarCollapseButton"; diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index f46535ba7..ebc56acca 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -631,40 +631,21 @@ "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", "start_new_chat_hint": "Start a new chat", "error_loading_chats": "Error loading chats", "chat_deleted": "Chat deleted successfully", "error_deleting_chat": "Failed to delete chat", - "search_space": "Search Space", - "notes": "Notes", - "all_notes": "All Notes", - "all_notes_description": "Browse and manage all your notes", - "search_notes": "Search notes...", - "no_results_found": "No notes found", - "try_different_search": "Try a different search term", - "no_notes": "No notes yet", - "create_new_note": "Create a new note", - "error_loading_notes": "Error loading notes", - "loading": "Loading...", - "deleting": "Deleting...", "delete": "Delete", - "created": "Created", + "try_different_search": "Try a different search term", "updated": "Updated", "more_options": "More options", "clear_search": "Clear search", - "view_all_notes": "View all notes", - "add_note": "Add note", "archive": "Archive", "unarchive": "Restore", "chat_archived": "Chat archived", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 59dc0ca20..fe13de4c0 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -631,40 +631,27 @@ "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": "暂无对话", "start_new_chat_hint": "开始新对话", "error_loading_chats": "加载对话时出错", "chat_deleted": "对话删除成功", "error_deleting_chat": "删除对话失败", - "search_space": "搜索空间", - "notes": "笔记", - "all_notes": "所有笔记", - "all_notes_description": "浏览和管理您的所有笔记", - "search_notes": "搜索笔记...", - "no_results_found": "未找到笔记", - "try_different_search": "尝试其他搜索词", - "no_notes": "暂无笔记", - "create_new_note": "创建新笔记", - "error_loading_notes": "加载笔记时出错", - "loading": "加载中...", - "deleting": "删除中...", "delete": "删除", - "created": "创建时间", + "try_different_search": "尝试其他搜索词", "updated": "更新时间", "more_options": "更多选项", "clear_search": "清除搜索", - "view_all_notes": "查看所有笔记", - "add_note": "添加笔记", + "archive": "归档", + "unarchive": "恢复", + "chat_archived": "对话已归档", + "chat_unarchived": "对话已恢复", + "no_archived_chats": "暂无已归档对话", + "error_archiving_chat": "归档对话失败", "new_chat": "新对话", "select_search_space": "选择搜索空间", "manage_members": "管理成员", From b4ff819efc4473a97e6712be871e4e7e30a608e4 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 13 Jan 2026 00:52:37 -0800 Subject: [PATCH 13/18] feat: update README to include Microsoft Teams --- README.md | 3 ++- README.zh-CN.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4f2ce4332..77c34334d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ # SurfSense Connect any LLM to your internal knowledge sources and chat with it in real time alongside your team. OSS alternative to NotebookLM, Perplexity, and Glean. -SurfSense is a highly customizable AI research agent, connected to external sources such as Search Engines (SearxNG, Tavily, LinkUp), Google Drive, Slack, Linear, Jira, ClickUp, Confluence, BookStack, Gmail, Notion, YouTube, GitHub, Discord, Airtable, Google Calendar, Luma, Circleback, Elasticsearch and more to come. +SurfSense is a highly customizable AI research agent, connected to external sources such as Search Engines (SearxNG, Tavily, LinkUp), Google Drive, Slack, Microsoft Teams, Linear, Jira, ClickUp, Confluence, BookStack, Gmail, Notion, YouTube, GitHub, Discord, Airtable, Google Calendar, Luma, Circleback, Elasticsearch and more to come.
MODSetter%2FSurfSense | Trendshift @@ -97,6 +97,7 @@ Contributors can easily add new tools via the registry pattern: - SearxNG (self-hosted instances) - Google Drive - Slack +- Microsoft Teams - Linear - Jira - ClickUp diff --git a/README.zh-CN.md b/README.zh-CN.md index fe6ec8e30..5eb369287 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -18,7 +18,7 @@ 将任何 LLM 连接到您的内部知识源,并与团队成员实时聊天。NotebookLM、Perplexity 和 Glean 的开源替代方案。 -SurfSense 是一个高度可定制的 AI 研究助手,可以连接外部数据源,如搜索引擎(SearxNG、Tavily、LinkUp)、Google Drive、Slack、Linear、Jira、ClickUp、Confluence、BookStack、Gmail、Notion、YouTube、GitHub、Discord、Airtable、Google Calendar、Luma、Circleback、Elasticsearch 等,未来还会支持更多。 +SurfSense 是一个高度可定制的 AI 研究助手,可以连接外部数据源,如搜索引擎(SearxNG、Tavily、LinkUp)、Google Drive、Slack、Microsoft Teams、Linear、Jira、ClickUp、Confluence、BookStack、Gmail、Notion、YouTube、GitHub、Discord、Airtable、Google Calendar、Luma、Circleback、Elasticsearch 等,未来还会支持更多。
MODSetter%2FSurfSense | Trendshift @@ -105,6 +105,7 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7 - SearxNG(自托管实例) - Google Drive - Slack +- Microsoft Teams - Linear - Jira - ClickUp From fbeffd58fe9c1f9468085d14187f105cb0f2f604 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 13 Jan 2026 01:07:08 -0800 Subject: [PATCH 14/18] refactor: simplify MobileSidebar component by removing unused props --- .../layout/ui/sidebar/MobileSidebar.tsx | 54 +++++++------------ 1 file changed, 19 insertions(+), 35 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index 0efec49c9..a57fd045c 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -111,29 +111,24 @@ export function MobileSidebar({
- {/* Sidebar Content */} -
- { - onNewChat(); - onOpenChange(false); - }} - onChatSelect={handleChatSelect} - onChatDelete={onChatDelete} - onViewAllChats={onViewAllChats} - notes={notes} - activeNoteId={activeNoteId} - onNoteSelect={handleNoteSelect} - onNoteDelete={onNoteDelete} - onAddNote={onAddNote} - onViewAllNotes={onViewAllNotes} + {/* Sidebar Content */} +
+ { + onNewChat(); + onOpenChange(false); + }} + onChatSelect={handleChatSelect} + onChatDelete={onChatDelete} + onViewAllSharedChats={onViewAllSharedChats} + onViewAllPrivateChats={onViewAllPrivateChats} user={user} onSettings={onSettings} onManageMembers={onManageMembers} @@ -142,18 +137,7 @@ export function MobileSidebar({ pageUsage={pageUsage} className="w-full border-none" /> - onViewAllSharedChats={onViewAllSharedChats} - onViewAllPrivateChats={onViewAllPrivateChats} - user={user} - onSettings={onSettings} - onManageMembers={onManageMembers} - onSeeAllSearchSpaces={onSeeAllSearchSpaces} - onUserSettings={onUserSettings} - onLogout={onLogout} - pageUsage={pageUsage} - className="w-full border-none" - /> -
+
); From 25b911830656a7c9b02edb036668091d48a1ac95 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 13 Jan 2026 01:45:58 -0800 Subject: [PATCH 15/18] feat: implement search space deletion and fixed rback issues with shared chats --- .../app/routes/new_chat_routes.py | 121 +++++++--- .../app/tasks/chat/stream_new_chat.py | 19 +- .../documents/(manage)/page.tsx | 22 +- .../new-chat/[[...chat_id]]/page.tsx | 8 +- .../[search_space_id]/settings/page.tsx | 5 +- .../app/dashboard/user/settings/page.tsx | 1 - .../atoms/chat/mentioned-documents.atom.ts | 2 +- .../components/assistant-ui/composer.tsx | 32 ++- .../assistant-ui/inline-mention-editor.tsx | 4 +- .../components/assistant-ui/markdown-text.tsx | 2 +- .../components/assistant-ui/thread.tsx | 32 ++- .../layout/providers/LayoutDataProvider.tsx | 94 +++++++- .../layout/ui/icon-rail/IconRail.tsx | 10 + .../layout/ui/icon-rail/SearchSpaceAvatar.tsx | 116 +++++++-- .../layout/ui/shell/LayoutShell.tsx | 8 + .../layout/ui/sidebar/MobileSidebar.tsx | 64 ++--- .../new-chat/document-mention-picker.tsx | 14 +- surfsense_web/components/ui/context-menu.tsx | 225 ++++++++++++++++++ .../lib/apis/documents-api.service.ts | 2 +- surfsense_web/messages/en.json | 3 + surfsense_web/package.json | 1 + surfsense_web/pnpm-lock.yaml | 30 +++ 22 files changed, 671 insertions(+), 144 deletions(-) create mode 100644 surfsense_web/components/ui/context-menu.tsx 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 From 6622a8c582afb8dedc8ae3684ccaeb0eb2921233 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 13 Jan 2026 01:48:43 -0800 Subject: [PATCH 16/18] feat: add custom callout component to MarkdownViewer for enhanced styling --- surfsense_web/components/markdown-viewer.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/surfsense_web/components/markdown-viewer.tsx b/surfsense_web/components/markdown-viewer.tsx index 93e3f26e1..91f97830a 100644 --- a/surfsense_web/components/markdown-viewer.tsx +++ b/surfsense_web/components/markdown-viewer.tsx @@ -10,6 +10,14 @@ interface MarkdownViewerProps { export function MarkdownViewer({ content, className }: MarkdownViewerProps) { const components: StreamdownProps["components"] = { // Define custom components for markdown elements + callout: ({ children, ...props }) => ( +
+ {children} +
+ ), p: ({ children, ...props }) => (

{children} From d140f6393e82d9eb51d3d32f058e84ef17359f1f Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 13 Jan 2026 02:06:35 -0800 Subject: [PATCH 17/18] feat: implement leave search space functionality for non-owners and update related UI components --- surfsense_backend/app/routes/rbac_routes.py | 93 ++++++++++--------- .../layout/providers/LayoutDataProvider.tsx | 82 +++++++++++++++- .../contracts/types/search-space.types.ts | 7 ++ .../lib/apis/search-spaces-api.service.ts | 12 +++ surfsense_web/messages/en.json | 5 + surfsense_web/messages/zh.json | 4 + 6 files changed, 154 insertions(+), 49 deletions(-) diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py index c5392f284..e90970b29 100644 --- a/surfsense_backend/app/routes/rbac_routes.py +++ b/surfsense_backend/app/routes/rbac_routes.py @@ -556,6 +556,54 @@ async def update_member_role( ) from e +# NOTE: /members/me must be defined BEFORE /members/{membership_id} +# because FastAPI matches routes in order, and "me" would otherwise +# be interpreted as a membership_id (causing a 422 validation error) +@router.delete("/searchspaces/{search_space_id}/members/me") +async def leave_search_space( + search_space_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Leave a search space (remove own membership). + Owners cannot leave their search space. + """ + try: + result = await session.execute( + select(SearchSpaceMembership).filter( + SearchSpaceMembership.user_id == user.id, + SearchSpaceMembership.search_space_id == search_space_id, + ) + ) + db_membership = result.scalars().first() + + if not db_membership: + raise HTTPException( + status_code=404, + detail="You are not a member of this search space", + ) + + if db_membership.is_owner: + raise HTTPException( + status_code=400, + detail="Owners cannot leave their search space. Transfer ownership first or delete the search space.", + ) + + await session.delete(db_membership) + await session.commit() + return {"message": "Successfully left the search space"} + + except HTTPException: + raise + except Exception as e: + await session.rollback() + logger.error(f"Failed to leave search space: {e!s}", exc_info=True) + raise HTTPException( + status_code=500, detail=f"Failed to leave search space: {e!s}" + ) from e + + @router.delete("/searchspaces/{search_space_id}/members/{membership_id}") async def remove_member( search_space_id: int, @@ -608,51 +656,6 @@ async def remove_member( ) from e -@router.delete("/searchspaces/{search_space_id}/members/me") -async def leave_search_space( - search_space_id: int, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - """ - Leave a search space (remove own membership). - Owners cannot leave their search space. - """ - try: - result = await session.execute( - select(SearchSpaceMembership).filter( - SearchSpaceMembership.user_id == user.id, - SearchSpaceMembership.search_space_id == search_space_id, - ) - ) - db_membership = result.scalars().first() - - if not db_membership: - raise HTTPException( - status_code=404, - detail="You are not a member of this search space", - ) - - if db_membership.is_owner: - raise HTTPException( - status_code=400, - detail="Owners cannot leave their search space. Transfer ownership first or delete the search space.", - ) - - await session.delete(db_membership) - await session.commit() - return {"message": "Successfully left the search space"} - - except HTTPException: - raise - except Exception as e: - await session.rollback() - logger.error(f"Failed to leave search space: {e!s}", exc_info=True) - raise HTTPException( - status_code=500, detail=f"Failed to leave search space: {e!s}" - ) from e - - # ============ Invite Endpoints ============ diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index dbc9c5f6a..3d4e5630d 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; -import { Logs, SquareLibrary, Trash2 } from "lucide-react"; +import { LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; @@ -86,10 +86,13 @@ export function LayoutDataProvider({ const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [isDeletingChat, setIsDeletingChat] = useState(false); - // Delete search space dialog state + // Delete/Leave search space dialog state const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false); + const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false); const [searchSpaceToDelete, setSearchSpaceToDelete] = useState(null); + const [searchSpaceToLeave, setSearchSpaceToLeave] = useState(null); const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false); + const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false); const searchSpaces: SearchSpace[] = useMemo(() => { if (!searchSpacesData || !Array.isArray(searchSpacesData)) return []; @@ -181,8 +184,14 @@ export function LayoutDataProvider({ ); const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => { - setSearchSpaceToDelete(space); - setShowDeleteSearchSpaceDialog(true); + // If user is owner, show delete dialog; otherwise show leave dialog + if (space.isOwner) { + setSearchSpaceToDelete(space); + setShowDeleteSearchSpaceDialog(true); + } else { + setSearchSpaceToLeave(space); + setShowLeaveSearchSpaceDialog(true); + } }, []); const confirmDeleteSearchSpace = useCallback(async () => { @@ -215,6 +224,29 @@ export function LayoutDataProvider({ router, ]); + const confirmLeaveSearchSpace = useCallback(async () => { + if (!searchSpaceToLeave) return; + setIsLeavingSearchSpace(true); + try { + await searchSpacesApiService.leaveSearchSpace(searchSpaceToLeave.id); + refetchSearchSpaces(); + if (Number(searchSpaceId) === searchSpaceToLeave.id && searchSpaces.length > 1) { + const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToLeave.id); + if (remaining.length > 0) { + router.push(`/dashboard/${remaining[0].id}/new-chat`); + } + } else if (searchSpaces.length === 1) { + router.push("/dashboard"); + } + } catch (error) { + console.error("Error leaving search space:", error); + } finally { + setIsLeavingSearchSpace(false); + setShowLeaveSearchSpaceDialog(false); + setSearchSpaceToLeave(null); + } + }, [searchSpaceToLeave, refetchSearchSpaces, searchSpaceId, searchSpaces, router]); + const handleNavItemClick = useCallback( (item: NavItem) => { router.push(item.url); @@ -422,6 +454,48 @@ export function LayoutDataProvider({ + {/* Leave Search Space Dialog */} +

+ + + + + {t("leave_title")} + + + {t("leave_confirm", { name: searchSpaceToLeave?.name || "" })} + + + + + + + + + {/* All Shared Chats Sidebar */} ; export type GetSearchSpacesRequest = z.infer; diff --git a/surfsense_web/lib/apis/search-spaces-api.service.ts b/surfsense_web/lib/apis/search-spaces-api.service.ts index ff60c513b..3e2006e46 100644 --- a/surfsense_web/lib/apis/search-spaces-api.service.ts +++ b/surfsense_web/lib/apis/search-spaces-api.service.ts @@ -11,6 +11,7 @@ import { getSearchSpaceResponse, getSearchSpacesRequest, getSearchSpacesResponse, + leaveSearchSpaceResponse, type UpdateSearchSpaceRequest, updateSearchSpaceRequest, updateSearchSpaceResponse, @@ -115,6 +116,17 @@ class SearchSpacesApiService { return baseApiService.delete(`/api/v1/searchspaces/${request.id}`, deleteSearchSpaceResponse); }; + + /** + * Leave a search space (remove own membership) + * This is used by non-owners to leave a shared search space + */ + leaveSearchSpace = async (searchSpaceId: number) => { + return baseApiService.delete( + `/api/v1/searchspaces/${searchSpaceId}/members/me`, + leaveSearchSpaceResponse + ); + }; } export const searchSpacesApiService = new SearchSpacesApiService(); diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index d55f70bf7..c0c579070 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -100,6 +100,7 @@ "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.", + "leaving": "Leaving...", "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" @@ -162,6 +163,10 @@ "go_home": "Go Home", "delete_search_space": "Delete Search Space", "delete_space_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone. All documents and chats in this search space will be permanently deleted.", + "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.", + "leaving": "Leaving...", "no_spaces_found": "No search spaces found", "create_first_space": "Create your first search space to get started", "created": "Created" diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index d0e6e50d7..f01ccda4b 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -159,6 +159,10 @@ "go_home": "返回首页", "delete_search_space": "删除搜索空间", "delete_space_confirm": "您确定要删除\"{name}\"吗?此操作无法撤销。此搜索空间中的所有文档、对话和播客将被永久删除。", + "leave": "退出", + "leave_title": "退出搜索空间", + "leave_confirm": "您确定要退出\"{name}\"吗?您将无法访问此搜索空间中的所有文档和对话。", + "leaving": "退出中...", "no_spaces_found": "未找到搜索空间", "create_first_space": "创建您的第一个搜索空间以开始使用", "created": "创建于" From 8646fecc8b965a5607a23a25a5569940964461a8 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 13 Jan 2026 02:15:46 -0800 Subject: [PATCH 18/18] feat: add document upload functionality and update UI components for document management --- .../[search_space_id]/documents/(manage)/page.tsx | 10 ++++++++-- .../components/assistant-ui/document-upload-popup.tsx | 10 +++------- surfsense_web/messages/en.json | 1 + surfsense_web/messages/zh.json | 1 + 4 files changed, 13 insertions(+), 9 deletions(-) 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 7d266cafe..54fd490a1 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,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; -import { RefreshCw, SquarePlus } from "lucide-react"; +import { RefreshCw, SquarePlus, Upload } from "lucide-react"; import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -10,6 +10,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react" import { toast } from "sonner"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; +import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; import { Button } from "@/components/ui/button"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { useLogsSummary } from "@/hooks/use-logs"; @@ -36,6 +37,7 @@ export default function DocumentsTable() { const params = useParams(); const router = useRouter(); const searchSpaceId = Number(params.search_space_id); + const { openDialog: openUploadDialog } = useDocumentUploadDialog(); const handleNewNote = useCallback(() => { router.push(`/dashboard/${searchSpaceId}/editor/new`); @@ -365,7 +367,11 @@ export default function DocumentsTable() {

{t("subtitle")}

- + diff --git a/surfsense_web/components/assistant-ui/document-upload-popup.tsx b/surfsense_web/components/assistant-ui/document-upload-popup.tsx index 6ac1ec979..453c6abde 100644 --- a/surfsense_web/components/assistant-ui/document-upload-popup.tsx +++ b/surfsense_web/components/assistant-ui/document-upload-popup.tsx @@ -86,7 +86,6 @@ const DocumentUploadPopupContent: FC<{ }> = ({ isOpen, onOpenChange }) => { const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const router = useRouter(); - const [isAccordionExpanded, setIsAccordionExpanded] = useState(false); if (!searchSpaceId) return null; @@ -118,19 +117,16 @@ const DocumentUploadPopupContent: FC<{ {/* Scrollable Content */}
-
+
- {/* Bottom fade shadow - only show when scrolling */} - {isAccordionExpanded && ( -
- )} + {/* Bottom fade shadow */} +
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index c0c579070..ae7f98843 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -319,6 +319,7 @@ "rows_per_page": "Rows per page", "refresh": "Refresh", "refresh_success": "Documents refreshed", + "upload_documents": "Upload Documents", "create_shared_note": "Create Shared Note", "processing_documents": "Processing documents...", "active_tasks_count": "{count} active task(s)" diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index f01ccda4b..1404c176f 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -315,6 +315,7 @@ "rows_per_page": "每页行数", "refresh": "刷新", "refresh_success": "文档已刷新", + "upload_documents": "上传文档", "create_shared_note": "创建共享笔记", "processing_documents": "正在处理文档...", "active_tasks_count": "{count} 个正在进行的工作项"