diff --git a/surfsense_backend/app/routes/chats_routes.py b/surfsense_backend/app/routes/chats_routes.py index 2813201e7..26af68149 100644 --- a/surfsense_backend/app/routes/chats_routes.py +++ b/surfsense_backend/app/routes/chats_routes.py @@ -319,15 +319,19 @@ async def read_chats( "You don't have permission to read chats in this search space", ) # Select specific fields excluding messages - query = select( - Chat.id, - Chat.type, - Chat.title, - Chat.initial_connectors, - Chat.search_space_id, - Chat.created_at, - Chat.state_version, - ).filter(Chat.search_space_id == search_space_id) + query = ( + select( + Chat.id, + Chat.type, + Chat.title, + Chat.initial_connectors, + Chat.search_space_id, + Chat.created_at, + Chat.state_version, + ) + .filter(Chat.search_space_id == search_space_id) + .order_by(Chat.created_at.desc()) + ) else: # Get chats from all search spaces user has membership in query = ( @@ -343,6 +347,7 @@ async def read_chats( .join(SearchSpace) .join(SearchSpaceMembership) .filter(SearchSpaceMembership.user_id == user.id) + .order_by(Chat.created_at.desc()) ) result = await session.execute(query.offset(skip).limit(limit)) diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx deleted file mode 100644 index 1d00ef01a..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx +++ /dev/null @@ -1,458 +0,0 @@ -"use client"; - -import { format } from "date-fns"; -import { useAtom, useAtomValue } from "jotai"; -import { - Calendar, - ExternalLink, - MessageCircleMore, - MoreHorizontal, - Search, - Tag, - Trash2, -} from "lucide-react"; -import { AnimatePresence, motion, type Variants } from "motion/react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; -import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms"; -import { chatsAtom } from "@/atoms/chats/chat-query.atoms"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from "@/components/ui/pagination"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; - -export interface Chat { - created_at: string; - id: number; - type: "QNA"; - title: string; - search_space_id: number; - state_version: number; -} - -export interface ChatDetails { - type: "QNA"; - title: string; - initial_connectors: string[]; - messages: any[]; - created_at: string; - id: number; - search_space_id: number; - state_version: number; -} - -interface ChatsPageClientProps { - searchSpaceId: string; -} - -const pageVariants: Variants = { - initial: { opacity: 0 }, - enter: { opacity: 1, transition: { duration: 0.3, ease: "easeInOut" } }, - exit: { opacity: 0, transition: { duration: 0.3, ease: "easeInOut" } }, -}; - -const chatCardVariants: Variants = { - initial: { y: 20, opacity: 0 }, - animate: { y: 0, opacity: 1 }, - exit: { y: -20, opacity: 0 }, -}; - -const MotionCard = motion(Card); - -export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) { - const router = useRouter(); - const [filteredChats, setFilteredChats] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); - const [currentPage, setCurrentPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const [selectedType, setSelectedType] = useState("all"); - const [sortOrder, setSortOrder] = useState("newest"); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [chatToDelete, setChatToDelete] = useState<{ - id: number; - title: string; - } | null>(null); - const { isFetching: isFetchingChats, data: chats, error: fetchError } = useAtomValue(chatsAtom); - const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] = - useAtom(deleteChatMutationAtom); - - const chatsPerPage = 9; - const searchParams = useSearchParams(); - - // Get initial page from URL params if it exists - useEffect(() => { - const pageParam = searchParams.get("page"); - if (pageParam) { - const pageNumber = parseInt(pageParam, 10); - if (!Number.isNaN(pageNumber) && pageNumber > 0) { - setCurrentPage(pageNumber); - } - } - }, [searchParams]); - - // Filter and sort chats based on search query, type, and sort order - useEffect(() => { - let result = [...(chats || [])]; - - // Filter by search term - if (searchQuery) { - const query = searchQuery.toLowerCase(); - result = result.filter((chat) => chat.title.toLowerCase().includes(query)); - } - - // Filter by type - if (selectedType !== "all") { - result = result.filter((chat) => chat.type === selectedType); - } - - // Sort chats - result.sort((a, b) => { - const dateA = new Date(a.created_at).getTime(); - const dateB = new Date(b.created_at).getTime(); - - return sortOrder === "newest" ? dateB - dateA : dateA - dateB; - }); - - setFilteredChats(result); - setTotalPages(Math.max(1, Math.ceil(result.length / chatsPerPage))); - - // Reset to first page when filters change - if (currentPage !== 1 && (searchQuery || selectedType !== "all" || sortOrder !== "newest")) { - setCurrentPage(1); - } - }, [chats, searchQuery, selectedType, sortOrder, currentPage]); - - // Function to handle chat deletion - const handleDeleteChat = async () => { - if (!chatToDelete) return; - - await deleteChat({ id: chatToDelete.id }); - - setDeleteDialogOpen(false); - setChatToDelete(null); - }; - - // Calculate pagination - const indexOfLastChat = currentPage * chatsPerPage; // Index of last chat in the current page - const indexOfFirstChat = indexOfLastChat - chatsPerPage; // Index of first chat in the current page - const currentChats = filteredChats.slice(indexOfFirstChat, indexOfLastChat); - - // Get unique chat types for filter dropdown - const chatTypes = chats ? ["all", ...Array.from(new Set(chats.map((chat) => chat.type)))] : []; - - return ( - -
-
-

All Chats

-

View, search, and manage all your chats.

-
- - {/* Filter and Search Bar */} -
-
-
- - setSearchQuery(e.target.value)} - /> -
- - -
- -
- -
-
- - {/* Status Messages */} - {isFetchingChats && ( -
-
-
-

Loading chats...

-
-
- )} - - {fetchError && !isFetchingChats && ( -
-

Error loading chats

-

{fetchError.message}

-
- )} - - {!isFetchingChats && !fetchError && filteredChats.length === 0 && ( -
- -

No chats found

-

- {searchQuery || selectedType !== "all" - ? "Try adjusting your search filters" - : "Start a new chat to get started"} -

-
- )} - - {/* Chat Grid */} - {!isFetchingChats && !fetchError && filteredChats.length > 0 && ( - -
- {currentChats.map((chat, index) => ( - - -
-
- - {chat.title || `Chat ${chat.id}`} - - - - - {format(new Date(chat.created_at), "MMM d, yyyy")} - - -
- - - - - - - router.push( - `/dashboard/${chat.search_space_id}/researcher/${chat.id}` - ) - } - > - - View Chat - - - { - e.stopPropagation(); - setChatToDelete({ - id: chat.id, - title: chat.title || `Chat ${chat.id}`, - }); - setDeleteDialogOpen(true); - }} - > - - Delete Chat - - - -
-
- - - - - {chat.type || "Unknown"} - - - -
- ))} -
-
- )} - - {/* Pagination */} - {!isFetchingChats && !fetchError && totalPages > 1 && ( - - - - { - e.preventDefault(); - if (currentPage > 1) setCurrentPage(currentPage - 1); - }} - className={currentPage <= 1 ? "pointer-events-none opacity-50" : ""} - /> - - - {Array.from({ length: totalPages }).map((_, index) => { - const pageNumber = index + 1; - const isVisible = - pageNumber === 1 || - pageNumber === totalPages || - (pageNumber >= currentPage - 1 && pageNumber <= currentPage + 1); - - if (!isVisible) { - // Show ellipsis at appropriate positions - if (pageNumber === 2 || pageNumber === totalPages - 1) { - return ( - - ... - - ); - } - return null; - } - - return ( - - { - e.preventDefault(); - setCurrentPage(pageNumber); - }} - isActive={pageNumber === currentPage} - > - {pageNumber} - - - ); - })} - - - { - e.preventDefault(); - if (currentPage < totalPages) setCurrentPage(currentPage + 1); - }} - className={currentPage >= totalPages ? "pointer-events-none opacity-50" : ""} - /> - - - - )} -
- - {/* Delete Confirmation Dialog */} - - - - - - Delete Chat - - - Are you sure you want to delete{" "} - {chatToDelete?.title}? This action cannot be - undone. - - - - - - - - -
- ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/page.tsx deleted file mode 100644 index 9a2bb0f48..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Suspense } from "react"; -import ChatsPageClient from "./chats-client"; - -interface PageProps { - params: { - search_space_id: string; - }; -} - -export default async function ChatsPage({ params }: PageProps) { - // Get search space ID from the route parameter - const { search_space_id: searchSpaceId } = await Promise.resolve(params); - - 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 4888faceb..8fc2fb825 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 @@ -1,11 +1,13 @@ "use client"; import { useQueryClient } from "@tanstack/react-query"; +import { useAtom } from "jotai"; import { AlertCircle, ArrowLeft, FileText, Loader2, Save } from "lucide-react"; import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms"; import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor"; import { AlertDialog, @@ -76,6 +78,46 @@ export default function EditorPage() { const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); + // Global state for cross-component communication + const [, setGlobalHasUnsavedChanges] = useAtom(hasUnsavedEditorChangesAtom); + const [pendingNavigation, setPendingNavigation] = useAtom(pendingEditorNavigationAtom); + + // Sync local unsaved changes state with global atom + useEffect(() => { + setGlobalHasUnsavedChanges(hasUnsavedChanges); + }, [hasUnsavedChanges, setGlobalHasUnsavedChanges]); + + // Cleanup global state when component unmounts + useEffect(() => { + return () => { + setGlobalHasUnsavedChanges(false); + setPendingNavigation(null); + }; + }, [setGlobalHasUnsavedChanges, setPendingNavigation]); + + // Handle pending navigation from sidebar (e.g., when user clicks "+" to create new note) + useEffect(() => { + if (pendingNavigation) { + if (hasUnsavedChanges) { + // Show dialog to confirm navigation + setShowUnsavedDialog(true); + } else { + // No unsaved changes, navigate immediately + router.push(pendingNavigation); + setPendingNavigation(null); + } + } + }, [pendingNavigation, hasUnsavedChanges, router, setPendingNavigation]); + + // Reset state when documentId changes (e.g., navigating from existing note to new note) + useEffect(() => { + setDocument(null); + setEditorContent(null); + setError(null); + setHasUnsavedChanges(false); + setLoading(true); + }, [documentId]); + // Fetch document content - DIRECT CALL TO FASTAPI // Skip fetching if this is a new note useEffect(() => { @@ -287,7 +329,23 @@ export default function EditorPage() { const handleConfirmLeave = () => { setShowUnsavedDialog(false); - router.push(`/dashboard/${searchSpaceId}/researcher`); + // Clear global unsaved state + setGlobalHasUnsavedChanges(false); + setHasUnsavedChanges(false); + + // If there's a pending navigation (from sidebar), use that; otherwise go back to researcher + if (pendingNavigation) { + router.push(pendingNavigation); + setPendingNavigation(null); + } else { + router.push(`/dashboard/${searchSpaceId}/researcher`); + } + }; + + const handleCancelLeave = () => { + setShowUnsavedDialog(false); + // Clear pending navigation if user cancels + setPendingNavigation(null); }; if (loading) { @@ -402,6 +460,7 @@ export default function EditorPage() { )}
{/* Unsaved Changes Dialog */} - + { + if (!open) handleCancelLeave(); + }} + > Unsaved Changes @@ -420,7 +484,7 @@ export default function EditorPage() { - Cancel + Cancel OK diff --git a/surfsense_web/app/dashboard/[search_space_id]/researcher/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/researcher/[[...chat_id]]/page.tsx index 196f565a9..60980f034 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/researcher/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/researcher/[[...chat_id]]/page.tsx @@ -134,9 +134,13 @@ export default function ResearcherPage() { message: Message | CreateMessage, chatRequestOptions?: { data?: any } ) => { + // Use the first message content as the chat title (truncated to 100 chars) + const messageContent = typeof message.content === "string" ? message.content : ""; + const chatTitle = messageContent.slice(0, 100) || "Untitled Chat"; + const newChat = await createChat({ type: researchMode, - title: "Untitled Chat", + title: chatTitle, initial_connectors: selectedConnectors, messages: [ { diff --git a/surfsense_web/atoms/chats/chat-mutation.atoms.ts b/surfsense_web/atoms/chats/chat-mutation.atoms.ts index a08dcd21f..c761a706f 100644 --- a/surfsense_web/atoms/chats/chat-mutation.atoms.ts +++ b/surfsense_web/atoms/chats/chat-mutation.atoms.ts @@ -1,7 +1,7 @@ import { atomWithMutation } from "jotai-tanstack-query"; import { toast } from "sonner"; -import type { Chat } from "@/app/dashboard/[search_space_id]/chats/chats-client"; import type { + ChatSummary, CreateChatRequest, DeleteChatRequest, UpdateChatRequest, @@ -27,12 +27,21 @@ export const deleteChatMutationAtom = atomWithMutation((get) => { onSuccess: (_, request: DeleteChatRequest) => { toast.success("Chat deleted successfully"); + // Optimistically update the current query queryClient.setQueryData( cacheKeys.chats.globalQueryParams(chatsQueryParams), - (oldData: Chat[]) => { - return oldData.filter((chat) => chat.id !== request.id); + (oldData: ChatSummary[]) => { + return oldData?.filter((chat) => chat.id !== request.id) ?? []; } ); + // Invalidate all chat queries to ensure consistency across components + queryClient.invalidateQueries({ + queryKey: ["chats"], + }); + // Also invalidate the "all-chats" query used by AllChatsSidebar + queryClient.invalidateQueries({ + queryKey: ["all-chats"], + }); }, }; }); @@ -50,8 +59,14 @@ export const createChatMutationAtom = atomWithMutation((get) => { }, onSuccess: () => { + // Invalidate ALL chat queries to ensure sidebar and other components refresh + // Using a partial key match to avoid stale closure issues with specific query params queryClient.invalidateQueries({ - queryKey: cacheKeys.chats.globalQueryParams(chatsQueryParams), + queryKey: ["chats"], + }); + // Also invalidate the "all-chats" query used by AllChatsSidebar + queryClient.invalidateQueries({ + queryKey: ["all-chats"], }); }, }; diff --git a/surfsense_web/atoms/editor/ui.atoms.ts b/surfsense_web/atoms/editor/ui.atoms.ts new file mode 100644 index 000000000..81a89a945 --- /dev/null +++ b/surfsense_web/atoms/editor/ui.atoms.ts @@ -0,0 +1,27 @@ +import { atom } from "jotai"; + +interface EditorUIState { + hasUnsavedChanges: boolean; + pendingNavigation: string | null; // URL to navigate to after user confirms +} + +export const editorUIAtom = atom({ + hasUnsavedChanges: false, + pendingNavigation: null, +}); + +// Derived atom for just the unsaved changes state +export const hasUnsavedEditorChangesAtom = atom( + (get) => get(editorUIAtom).hasUnsavedChanges, + (get, set, value: boolean) => { + set(editorUIAtom, { ...get(editorUIAtom), hasUnsavedChanges: value }); + } +); + +// Derived atom for pending navigation +export const pendingEditorNavigationAtom = atom( + (get) => get(editorUIAtom).pendingNavigation, + (get, set, value: string | null) => { + set(editorUIAtom, { ...get(editorUIAtom), pendingNavigation: value }); + } +); diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx index ca05b0e3f..aa1478be6 100644 --- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx +++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx @@ -9,6 +9,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms"; import { chatsAtom } from "@/atoms/chats/chat-query.atoms"; import { globalChatsQueryParamsAtom } from "@/atoms/chats/ui.atoms"; +import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { AppSidebar } from "@/components/sidebar/app-sidebar"; import { Button } from "@/components/ui/button"; @@ -56,9 +57,13 @@ export function AppSidebarProvider({ const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] = useAtom(deleteChatMutationAtom); + // Editor state for handling unsaved changes + const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom); + const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom); + useEffect(() => { - setChatsQueryParams((prev) => ({ ...prev, search_space_id: searchSpaceId, skip: 0, limit: 5 })); - }, [searchSpaceId]); + setChatsQueryParams((prev) => ({ ...prev, search_space_id: searchSpaceId, skip: 0, limit: 4 })); + }, [searchSpaceId, setChatsQueryParams]); const { data: searchSpace, @@ -84,13 +89,20 @@ export function AppSidebarProvider({ queryFn: () => notesApiService.getNotes({ search_space_id: Number(searchSpaceId), - page_size: 5, // Get 5 notes (changed from 10) + page_size: 4, // Get 4 notes for compact sidebar }), enabled: !!searchSpaceId, }); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); + const [showDeleteNoteDialog, setShowDeleteNoteDialog] = useState(false); + const [noteToDelete, setNoteToDelete] = useState<{ + id: number; + name: string; + search_space_id: number; + } | null>(null); + const [isDeletingNote, setIsDeletingNote] = useState(false); const [isClient, setIsClient] = useState(false); // Set isClient to true when component mounts on the client @@ -105,25 +117,32 @@ export function AppSidebarProvider({ // Transform API response to the format expected by AppSidebar const recentChats = useMemo(() => { - return chats - ? chats.map((chat) => ({ - name: chat.title || `Chat ${chat.id}`, - url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`, - icon: "MessageCircleMore", - id: chat.id, - search_space_id: chat.search_space_id, - actions: [ - { - name: "Delete", - icon: "Trash2", - onClick: () => { - setChatToDelete({ id: chat.id, name: chat.title || `Chat ${chat.id}` }); - setShowDeleteDialog(true); - }, - }, - ], - })) - : []; + if (!chats) return []; + + // Sort chats by created_at (most recent first) + const sortedChats = [...chats].sort((a, b) => { + const dateA = new Date(a.created_at).getTime(); + const dateB = new Date(b.created_at).getTime(); + return dateB - dateA; // Descending order (most recent first) + }); + + return sortedChats.map((chat) => ({ + name: chat.title || `Chat ${chat.id}`, + url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`, + icon: "MessageCircleMore", + id: chat.id, + search_space_id: chat.search_space_id, + actions: [ + { + name: "Delete", + icon: "Trash2", + onClick: () => { + setChatToDelete({ id: chat.id, name: chat.title || `Chat ${chat.id}` }); + setShowDeleteDialog(true); + }, + }, + ], + })); }, [chats]); // Handle delete chat with better error handling @@ -141,6 +160,26 @@ export function AppSidebarProvider({ } }, [chatToDelete, deleteChat]); + // Handle delete note with confirmation + const handleDeleteNote = useCallback(async () => { + if (!noteToDelete) return; + + setIsDeletingNote(true); + try { + await notesApiService.deleteNote({ + search_space_id: noteToDelete.search_space_id, + note_id: noteToDelete.id, + }); + refetchNotes(); + } catch (error) { + console.error("Error deleting note:", error); + } finally { + setIsDeletingNote(false); + setShowDeleteNoteDialog(false); + setNoteToDelete(null); + } + }, [noteToDelete, refetchNotes]); + // Memoized fallback chats const fallbackChats = useMemo(() => { if (chatError) { @@ -162,19 +201,6 @@ export function AppSidebarProvider({ ]; } - if (!isLoadingChats && recentChats.length === 0) { - return [ - { - name: t("no_recent_chats"), - url: "#", - icon: "MessageCircleMore", - id: 0, - search_space_id: Number(searchSpaceId), - actions: [], - }, - ]; - } - return []; }, [chatError, isLoadingChats, recentChats.length, searchSpaceId, retryFetch, t, tCommon]); @@ -196,8 +222,8 @@ export function AppSidebarProvider({ return dateB - dateA; // Descending order (most recent first) }); - // Limit to 5 notes - return sortedNotes.slice(0, 5).map((note) => ({ + // Limit to 4 notes for compact sidebar + return sortedNotes.slice(0, 4).map((note) => ({ name: note.title, url: `/dashboard/${note.search_space_id}/editor/${note.id}`, icon: "FileText", @@ -207,26 +233,31 @@ export function AppSidebarProvider({ { name: "Delete", icon: "Trash2", - onClick: async () => { - try { - await notesApiService.deleteNote({ - search_space_id: note.search_space_id, - note_id: note.id, - }); - refetchNotes(); - } catch (error) { - console.error("Error deleting note:", error); - } + onClick: () => { + setNoteToDelete({ + id: note.id, + name: note.title, + search_space_id: note.search_space_id, + }); + setShowDeleteNoteDialog(true); }, }, ], })); - }, [notesData, refetchNotes]); + }, [notesData]); - // Handle add note + // Handle add note - check for unsaved changes first const handleAddNote = useCallback(() => { - router.push(`/dashboard/${searchSpaceId}/editor/new`); - }, [router, searchSpaceId]); + const newNoteUrl = `/dashboard/${searchSpaceId}/editor/new`; + + if (hasUnsavedEditorChanges) { + // Set pending navigation - the editor will show the unsaved changes dialog + setPendingNavigation(newNoteUrl); + } else { + // No unsaved changes, navigate directly + router.push(newNoteUrl); + } + }, [router, searchSpaceId, hasUnsavedEditorChanges, setPendingNavigation]); // Memoized updated navSecondary const updatedNavSecondary = useMemo(() => { @@ -271,6 +302,7 @@ export function AppSidebarProvider({ navMain={navMain} RecentChats={[]} RecentNotes={[]} + onAddNote={handleAddNote} pageUsage={pageUsage} /> ); @@ -330,6 +362,49 @@ export function AppSidebarProvider({ + + {/* Delete Note Confirmation Dialog */} + + + + + + {t("delete_note")} + + + {t("delete_note_confirm")} {noteToDelete?.name}?{" "} + {t("action_cannot_undone")} + + + + + + + + ); } diff --git a/surfsense_web/components/sidebar/all-chats-sidebar.tsx b/surfsense_web/components/sidebar/all-chats-sidebar.tsx new file mode 100644 index 000000000..5feb4ce3d --- /dev/null +++ b/surfsense_web/components/sidebar/all-chats-sidebar.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { Loader2, MessageCircleMore, MoreHorizontal, Search, Trash2, X } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { chatsApiService } from "@/lib/apis/chats-api.service"; +import { cn } from "@/lib/utils"; + +interface AllChatsSidebarProps { + open: boolean; + onOpenChange: (open: boolean) => void; + searchSpaceId: string; +} + +export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsSidebarProps) { + const t = useTranslations("sidebar"); + const router = useRouter(); + const queryClient = useQueryClient(); + const [deletingChatId, setDeletingChatId] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); + + // Fetch all chats + const { + data: chatsData, + error: chatsError, + isLoading: isLoadingChats, + } = useQuery({ + queryKey: ["all-chats", searchSpaceId], + queryFn: () => + chatsApiService.getChats({ + queryParams: { + search_space_id: Number(searchSpaceId), + }, + }), + enabled: !!searchSpaceId && open, + }); + + // Handle chat navigation + const handleChatClick = useCallback( + (chatId: number, chatSearchSpaceId: number) => { + router.push(`/dashboard/${chatSearchSpaceId}/researcher/${chatId}`); + onOpenChange(false); + }, + [router, onOpenChange] + ); + + // Handle chat deletion + const handleDeleteChat = useCallback( + async (chatId: number) => { + setDeletingChatId(chatId); + try { + await chatsApiService.deleteChat({ id: chatId }); + toast.success(t("chat_deleted") || "Chat deleted successfully"); + // Invalidate queries to refresh the list + queryClient.invalidateQueries({ queryKey: ["all-chats", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["chats"] }); + } catch (error) { + console.error("Error deleting chat:", error); + toast.error(t("error_deleting_chat") || "Failed to delete chat"); + } finally { + setDeletingChatId(null); + } + }, + [queryClient, searchSpaceId, t] + ); + + // Clear search + const handleClearSearch = useCallback(() => { + setSearchQuery(""); + }, []); + + // Filter and sort chats based on search query (client-side filtering) + const chats = useMemo(() => { + const allChats = chatsData ?? []; + + // Sort chats by created_at (most recent first) + const sortedChats = [...allChats].sort((a, b) => { + const dateA = new Date(a.created_at).getTime(); + const dateB = new Date(b.created_at).getTime(); + return dateB - dateA; // Descending order (most recent first) + }); + + if (!debouncedSearchQuery) { + return sortedChats; + } + const query = debouncedSearchQuery.toLowerCase(); + return sortedChats.filter((chat) => chat.title.toLowerCase().includes(query)); + }, [chatsData, debouncedSearchQuery]); + + const isSearchMode = !!debouncedSearchQuery; + + return ( + + + + {t("all_chats") || "All Chats"} + + {t("all_chats_description") || "Browse and manage all your chats"} + + + {/* Search Input */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-8 h-9" + /> + {searchQuery && ( + + )} +
+
+ + +
+ {isLoadingChats ? ( +
+ +
+ ) : chatsError ? ( +
+ {t("error_loading_chats") || "Error loading chats"} +
+ ) : chats.length > 0 ? ( +
+ {chats.map((chat) => { + const isDeleting = deletingChatId === chat.id; + + return ( +
+ {/* Main clickable area for navigation */} + + + + + +

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

+
+
+ + {/* Actions dropdown - separate from main click area */} + + + + + + handleDeleteChat(chat.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"} +

+
+ ) : ( +
+ +

{t("no_chats") || "No chats yet"}

+

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

+
+ )} +
+
+
+
+ ); +} diff --git a/surfsense_web/components/sidebar/all-notes-sidebar.tsx b/surfsense_web/components/sidebar/all-notes-sidebar.tsx index 11c4f80ec..20622c13e 100644 --- a/surfsense_web/components/sidebar/all-notes-sidebar.tsx +++ b/surfsense_web/components/sidebar/all-notes-sidebar.tsx @@ -1,6 +1,7 @@ "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 { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -21,6 +22,7 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet"; +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"; @@ -121,22 +123,44 @@ export function AllNotesSidebar({ const isLoading = isSearchMode ? isSearching : isLoadingNotes; const error = isSearchMode ? searchError : notesError; - // Transform notes data - handle both regular notes and search results + // 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) { - return searchData.items.map((doc) => ({ + 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 ?? []; } - return 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]); return ( - + {t("all_notes") || "All Notes"} {t("all_notes_description") || "Browse and manage all your notes"} @@ -160,7 +184,7 @@ export function AllNotesSidebar({ onClick={handleClearSearch} > - Clear search + {t("clear_search") || "Clear search"} )}
@@ -192,15 +216,33 @@ export function AllNotesSidebar({ )} > {/* 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 */} @@ -220,7 +262,7 @@ export function AllNotesSidebar({ ) : ( )} - More options + {t("more_options") || "More options"} @@ -229,7 +271,7 @@ export function AllNotesSidebar({ className="text-destructive focus:text-destructive" > - Delete + {t("delete") || "Delete"} @@ -273,7 +315,7 @@ export function AllNotesSidebar({ {/* Footer with Add Note button */} {onAddNote && notes.length > 0 && ( -
+
+ )} +
+
+ + + {chats.length > 0 ? ( + + + {chats.map((chat) => { + const isDeletingChat = isDeleting === chat.id; + + return ( + + {/* Main navigation button */} + handleChatClick(chat.url)} + disabled={isDeletingChat} + className={cn( + "pr-8", // Make room for the action button + isDeletingChat && "opacity-50" + )} + > + + {chat.name} + + + {/* Actions dropdown - positioned absolutely */} + {chat.actions && chat.actions.length > 0 && ( +
+ + + + + + {chat.actions.map((action, actionIndex) => { + const ActionIcon = actionIconMap[action.icon] || MessageCircleMore; + const isDeleteAction = action.name.toLowerCase().includes("delete"); + + return ( + { + if (isDeleteAction) { + handleDeleteChat(chat.id || 0, action.onClick); + } else { + action.onClick(); + } + }} + disabled={isDeletingChat} + className={ + isDeleteAction + ? "text-destructive focus:text-destructive" + : "" + } + > + + + {isDeletingChat && isDeleteAction + ? t("deleting") || "Deleting..." + : action.name} + + + ); + })} + + +
+ )} +
+ ); + })} +
+
+ ) : ( +
+ + {t("no_recent_chats") || "No recent chats"} +
+ )} +
+ + + {/* All Chats Sheet */} + {searchSpaceId && ( + + )} + + ); +} diff --git a/surfsense_web/components/sidebar/nav-main.tsx b/surfsense_web/components/sidebar/nav-main.tsx index 274d77b33..606ab2680 100644 --- a/surfsense_web/components/sidebar/nav-main.tsx +++ b/surfsense_web/components/sidebar/nav-main.tsx @@ -2,7 +2,7 @@ import { ChevronRight, type LucideIcon } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { @@ -28,7 +28,12 @@ interface NavItem { }[]; } -export function NavMain({ items }: { items: NavItem[] }) { +interface NavMainProps { + items: NavItem[]; + onSourcesExpandedChange?: (expanded: boolean) => void; +} + +export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) { const t = useTranslations("nav_menu"); // Translation function that handles both exact matches and fallback to original @@ -53,6 +58,29 @@ export function NavMain({ items }: { items: NavItem[] }) { // Memoize items to prevent unnecessary re-renders const memoizedItems = useMemo(() => items, [items]); + // Track expanded state for items with sub-menus (like Sources) + const [expandedItems, setExpandedItems] = useState>(() => { + const initial: Record = {}; + items.forEach((item) => { + if (item.items?.length) { + initial[item.title] = item.isActive ?? false; + } + }); + return initial; + }); + + // Handle collapsible state change + const handleOpenChange = useCallback( + (title: string, isOpen: boolean) => { + setExpandedItems((prev) => ({ ...prev, [title]: isOpen })); + // Notify parent when Sources is expanded/collapsed + if (title === "Sources" && onSourcesExpandedChange) { + onSourcesExpandedChange(isOpen); + } + }, + [onSourcesExpandedChange] + ); + return ( {translateTitle("Platform")} @@ -60,8 +88,15 @@ export function NavMain({ items }: { items: NavItem[] }) { {memoizedItems.map((item, index) => { const translatedTitle = translateTitle(item.title); const hasSub = !!item.items?.length; + const isItemOpen = expandedItems[item.title] ?? item.isActive ?? false; return ( - + handleOpenChange(item.title, open) : undefined} + defaultOpen={!hasSub ? item.isActive : undefined} + > {hasSub ? ( // When the item has children, make the whole row a collapsible trigger diff --git a/surfsense_web/components/sidebar/nav-notes.tsx b/surfsense_web/components/sidebar/nav-notes.tsx index b14ecea77..f634c2b72 100644 --- a/surfsense_web/components/sidebar/nav-notes.tsx +++ b/surfsense_web/components/sidebar/nav-notes.tsx @@ -12,7 +12,7 @@ import { } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { @@ -29,6 +29,7 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; +import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import { AllNotesSidebar } from "./all-notes-sidebar"; @@ -52,6 +53,7 @@ interface NavNotesProps { onAddNote?: () => void; defaultOpen?: boolean; searchSpaceId?: string; + isSourcesExpanded?: boolean; } // Map of icon names to their components @@ -61,13 +63,27 @@ const actionIconMap: Record = { MoreHorizontal, }; -export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }: NavNotesProps) { +export function NavNotes({ + notes, + onAddNote, + defaultOpen = true, + searchSpaceId, + isSourcesExpanded = false, +}: NavNotesProps) { const t = useTranslations("sidebar"); const router = useRouter(); + const isMobile = useIsMobile(); const [isDeleting, setIsDeleting] = useState(null); const [isOpen, setIsOpen] = useState(defaultOpen); const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false); + // Auto-collapse on smaller screens when Sources is expanded + useEffect(() => { + if (isSourcesExpanded && isMobile) { + setIsOpen(false); + } + }, [isSourcesExpanded, isMobile]); + // Handle note deletion with loading state const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => { setIsDeleting(noteId); @@ -113,7 +129,7 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId } e.stopPropagation(); setIsAllNotesSidebarOpen(true); }} - aria-label="View all notes" + aria-label={t("view_all_notes") || "View all notes"} > @@ -127,7 +143,7 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId } e.stopPropagation(); onAddNote(); }} - aria-label="Add note" + aria-label={t("add_note") || "Add note"} > @@ -178,7 +194,9 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId } ) : ( )} - More options + + {t("more_options") || "More options"} + @@ -206,7 +224,7 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId } {isDeletingNote && isDeleteAction - ? "Deleting..." + ? t("deleting") || "Deleting..." : action.name} diff --git a/surfsense_web/components/sidebar/nav-projects.tsx b/surfsense_web/components/sidebar/nav-projects.tsx deleted file mode 100644 index 3862ce75d..000000000 --- a/surfsense_web/components/sidebar/nav-projects.tsx +++ /dev/null @@ -1,177 +0,0 @@ -"use client"; - -import { - ExternalLink, - Folder, - type LucideIcon, - MoreHorizontal, - RefreshCw, - Search, - Share, - Trash2, -} from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useMemo, useState } from "react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - SidebarGroup, - SidebarGroupLabel, - SidebarInput, - SidebarMenu, - SidebarMenuAction, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/components/ui/sidebar"; - -// Map of icon names to their components -const actionIconMap: Record = { - ExternalLink, - Folder, - Share, - Trash2, - MoreHorizontal, - Search, - RefreshCw, -}; - -interface ChatAction { - name: string; - icon: string; - onClick: () => void; -} - -interface ChatItem { - name: string; - url: string; - icon: LucideIcon; - id?: number; - search_space_id?: number; - actions?: ChatAction[]; -} - -export function NavProjects({ chats }: { chats: ChatItem[] }) { - const t = useTranslations("sidebar"); - const { isMobile } = useSidebar(); - const router = useRouter(); - const [searchQuery, setSearchQuery] = useState(""); - const [isDeleting, setIsDeleting] = useState(null); - - const searchSpaceId = chats[0]?.search_space_id || ""; - - // Memoized filtered chats - const filteredChats = useMemo(() => { - if (!searchQuery.trim()) return chats; - - return chats.filter((chat) => chat.name.toLowerCase().includes(searchQuery.toLowerCase())); - }, [chats, searchQuery]); - - // Handle chat deletion with loading state - const handleDeleteChat = useCallback(async (chatId: number, deleteAction: () => void) => { - setIsDeleting(chatId); - try { - await deleteAction(); - } finally { - setIsDeleting(null); - } - }, []); - - // Enhanced chat item component - const ChatItemComponent = useCallback( - ({ chat }: { chat: ChatItem }) => { - const isDeletingChat = isDeleting === chat.id; - - return ( - - router.push(chat.url)} - disabled={isDeletingChat} - className={isDeletingChat ? "opacity-50" : ""} - > - - {chat.name} - - - {chat.actions && chat.actions.length > 0 && ( - - - - - More - - - - {chat.actions.map((action, actionIndex) => { - const ActionIcon = actionIconMap[action.icon] || Folder; - const isDeleteAction = action.name.toLowerCase().includes("delete"); - - return ( - { - if (isDeleteAction) { - handleDeleteChat(chat.id || 0, action.onClick); - } else { - action.onClick(); - } - }} - disabled={isDeletingChat} - className={isDeleteAction ? "text-destructive" : ""} - > - - {isDeletingChat && isDeleteAction ? "Deleting..." : action.name} - - ); - })} - - - )} - - ); - }, - [isDeleting, router, isMobile, handleDeleteChat] - ); - - // Show search input if there are chats - const showSearch = chats.length > 0; - - return ( - - {t("recent_chats")} - - {/* Chat Items */} - {filteredChats.length > 0 ? ( - filteredChats.map((chat) => ) - ) : ( - /* No results state */ - - - - {searchQuery ? t("no_chats_found") : t("no_recent_chats")} - - - )} - - {/* View All Chats */} - {chats.length > 0 && ( - - router.push(`/dashboard/${searchSpaceId}/chats`)}> - - {t("view_all_chats")} - - - )} - - - ); -} diff --git a/surfsense_web/components/ui/sidebar.tsx b/surfsense_web/components/ui/sidebar.tsx index 3170148eb..caafa6b6e 100644 --- a/surfsense_web/components/ui/sidebar.tsx +++ b/surfsense_web/components/ui/sidebar.tsx @@ -365,7 +365,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
); diff --git a/surfsense_web/components/ui/tooltip.tsx b/surfsense_web/components/ui/tooltip.tsx index c065426ba..98420c858 100644 --- a/surfsense_web/components/ui/tooltip.tsx +++ b/surfsense_web/components/ui/tooltip.tsx @@ -42,13 +42,13 @@ function TooltipContent({ data-slot="tooltip-content" sideOffset={sideOffset} className={cn( - "bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance", + "bg-popover text-popover-foreground border shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance", className )} {...props} > {children} - + ); diff --git a/surfsense_web/contracts/types/chat.types.ts b/surfsense_web/contracts/types/chat.types.ts index 0b8b5975c..a4e5b8f5b 100644 --- a/surfsense_web/contracts/types/chat.types.ts +++ b/surfsense_web/contracts/types/chat.types.ts @@ -14,7 +14,7 @@ export const chatSummary = z.object({ }); export const chatDetails = chatSummary.extend({ - initial_connectors: z.array(z.string()), + initial_connectors: z.array(z.string()).nullable().optional(), messages: z.array(z.any()), }); diff --git a/surfsense_web/hooks/use-chat.ts b/surfsense_web/hooks/use-chat.ts index 108165bbf..c31097e11 100644 --- a/surfsense_web/hooks/use-chat.ts +++ b/surfsense_web/hooks/use-chat.ts @@ -1,6 +1,4 @@ -import type { Message } from "@ai-sdk/react"; -import { useCallback, useEffect, useState } from "react"; -import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; +import { useEffect, useState } from "react"; import type { ResearchMode } from "@/components/chat"; import type { Document } from "@/contracts/types/document.types"; import { getBearerToken } from "@/lib/auth-utils"; diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index a8eecb605..7eae21e05 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -10,7 +10,14 @@ export const cacheKeys = { chats: { activeChat: (chatId: string) => ["active-chat", chatId] as const, globalQueryParams: (queries: GetChatsRequest["queryParams"]) => - ["chats", ...(queries ? Object.values(queries) : [])] as const, + [ + "chats", + queries?.search_space_id, + queries?.limit, + queries?.skip, + queries?.page, + queries?.page_size, + ] as const, }, podcasts: { globalQueryParams: (queries: GetPodcastsRequest["queryParams"]) => diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index e2cf89b5f..43ec2b0aa 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -98,6 +98,8 @@ "unknown_search_space": "Unknown Search Space", "delete_chat": "Delete Chat", "delete_chat_confirm": "Are you sure you want to delete", + "delete_note": "Delete Note", + "delete_note_confirm": "Are you sure you want to delete", "action_cannot_undone": "This action cannot be undone.", "deleting": "Deleting...", "surfsense_dashboard": "SurfSense Dashboard", @@ -641,7 +643,14 @@ "search_chats": "Search chats...", "no_chats_found": "No chats found", "no_recent_chats": "No recent chats", - "view_all_chats": "View All Chats", + "view_all_chats": "View all 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 from the researcher", + "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", @@ -652,7 +661,15 @@ "no_notes": "No notes yet", "create_new_note": "Create a new note", "error_loading_notes": "Error loading notes", - "loading": "Loading..." + "loading": "Loading...", + "deleting": "Deleting...", + "delete": "Delete", + "created": "Created", + "updated": "Updated", + "more_options": "More options", + "clear_search": "Clear search", + "view_all_notes": "View all notes", + "add_note": "Add note" }, "errors": { "something_went_wrong": "Something went wrong", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 38546bb87..d33354e06 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -98,6 +98,8 @@ "unknown_search_space": "未知搜索空间", "delete_chat": "删除对话", "delete_chat_confirm": "您确定要删除", + "delete_note": "删除笔记", + "delete_note_confirm": "您确定要删除", "action_cannot_undone": "此操作无法撤销。", "deleting": "删除中...", "surfsense_dashboard": "SurfSense 仪表盘", @@ -642,6 +644,13 @@ "no_chats_found": "未找到对话", "no_recent_chats": "暂无最近对话", "view_all_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": "所有笔记", @@ -652,7 +661,15 @@ "no_notes": "暂无笔记", "create_new_note": "创建新笔记", "error_loading_notes": "加载笔记时出错", - "loading": "加载中..." + "loading": "加载中...", + "deleting": "删除中...", + "delete": "删除", + "created": "创建时间", + "updated": "更新时间", + "more_options": "更多选项", + "clear_search": "清除搜索", + "view_all_notes": "查看所有笔记", + "add_note": "添加笔记" }, "errors": { "something_went_wrong": "出错了",