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 5b35c0284..8fdd9eecb 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 @@ -33,7 +33,7 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { type AgentCreatedDocument, agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms"; import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { membersAtom } from "@/atoms/members/members-query.atoms"; -import { updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom"; +import { removeChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps"; import { Thread } from "@/components/assistant-ui/thread"; @@ -70,6 +70,7 @@ import { getThreadMessages, type ThreadRecord, } from "@/lib/chat/thread-persistence"; +import { NotFoundError } from "@/lib/error"; import { trackChatCreated, trackChatError, @@ -194,6 +195,7 @@ export default function NewChatPage() { const closeReportPanel = useSetAtom(closeReportPanelAtom); const closeEditorPanel = useSetAtom(closeEditorPanelAtom); const updateChatTabTitle = useSetAtom(updateChatTabTitleAtom); + const removeChatTab = useSetAtom(removeChatTabAtom); const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom); // Get current user for author info in shared chats @@ -323,6 +325,14 @@ export default function NewChatPage() { // This improves UX (instant load) and avoids orphan threads } catch (error) { console.error("[NewChatPage] Failed to initialize thread:", error); + if (urlChatId > 0 && error instanceof NotFoundError) { + removeChatTab(urlChatId); + if (typeof window !== "undefined") { + window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`); + } + toast.error("This chat was deleted."); + return; + } // Keep threadId as null - don't use Date.now() as it creates an invalid ID // that will cause 404 errors on subsequent API calls setThreadId(null); @@ -338,12 +348,14 @@ export default function NewChatPage() { setSidebarDocuments, closeReportPanel, closeEditorPanel, + removeChatTab, + searchSpaceId, ]); // Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same) useEffect(() => { initializeThread(); - }, [initializeThread, searchSpaceId]); + }, [initializeThread]); // Prefetch document titles for @ mention picker // Runs when user lands on page so data is ready when they type @ diff --git a/surfsense_web/atoms/tabs/tabs.atom.ts b/surfsense_web/atoms/tabs/tabs.atom.ts index bb747f032..2d462e4d5 100644 --- a/surfsense_web/atoms/tabs/tabs.atom.ts +++ b/surfsense_web/atoms/tabs/tabs.atom.ts @@ -33,6 +33,9 @@ const initialState: TabsState = { activeTabId: "chat-new", }; +// Prevent race conditions where route-sync recreates a just-deleted chat tab. +const deletedChatIdsAtom = atom>(new Set()); + const sessionStorageAdapter = createJSONStorage( () => (typeof window !== "undefined" ? sessionStorage : undefined) as Storage ); @@ -71,6 +74,10 @@ export const syncChatTabAtom = atom( set, { chatId, title, chatUrl }: { chatId: number | null; title?: string; chatUrl?: string } ) => { + if (chatId && get(deletedChatIdsAtom).has(chatId)) { + return; + } + const state = get(tabsStateAtom); const tabId = makeChatTabId(chatId); const existing = state.tabs.find((t) => t.id === tabId); @@ -235,6 +242,9 @@ export const removeChatTabAtom = atom(null, (get, set, chatId: number) => { const idx = state.tabs.findIndex((t) => t.id === tabId); if (idx === -1) return null; + const deletedChatIds = get(deletedChatIdsAtom); + set(deletedChatIdsAtom, new Set([...deletedChatIds, chatId])); + const remaining = state.tabs.filter((t) => t.id !== tabId); // Always keep at least one tab available. @@ -259,4 +269,5 @@ export const removeChatTabAtom = atom(null, (get, set, chatId: number) => { /** Reset tabs when switching search spaces. */ export const resetTabsAtom = atom(null, (_get, set) => { set(tabsStateAtom, { ...initialState }); + set(deletedChatIdsAtom, new Set()); }); diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index f611c1861..3db53285b 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -639,16 +639,20 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid setIsDeletingChat(true); try { await deleteThread(chatToDelete.id); - removeChatTab(chatToDelete.id); + const fallbackTab = removeChatTab(chatToDelete.id); queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); if (currentChatId === chatToDelete.id) { resetCurrentThread(); - const isOutOfSync = currentThreadState.id !== null && !params?.chat_id; - if (isOutOfSync) { - window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`); - setChatResetKey((k) => k + 1); + if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) { + router.push(fallbackTab.chatUrl); } else { - router.push(`/dashboard/${searchSpaceId}/new-chat`); + const isOutOfSync = currentThreadState.id !== null && !params?.chat_id; + if (isOutOfSync) { + window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`); + setChatResetKey((k) => k + 1); + } else { + router.push(`/dashboard/${searchSpaceId}/new-chat`); + } } } } catch (error) { diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx index 4a359859a..01e397309 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx @@ -161,7 +161,7 @@ export function AllPrivateChatsSidebarContent({ setDeletingThreadId(threadId); try { await deleteThread(threadId); - removeChatTab(threadId); + const fallbackTab = removeChatTab(threadId); toast.success(t("chat_deleted") || "Chat deleted successfully"); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); @@ -170,6 +170,10 @@ export function AllPrivateChatsSidebarContent({ if (currentChatId === threadId) { onOpenChange(false); setTimeout(() => { + if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) { + router.push(fallbackTab.chatUrl); + return; + } router.push(`/dashboard/${searchSpaceId}/new-chat`); }, 250); } diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx index 6e7828116..b13bf2ba3 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx @@ -161,7 +161,7 @@ export function AllSharedChatsSidebarContent({ setDeletingThreadId(threadId); try { await deleteThread(threadId); - removeChatTab(threadId); + const fallbackTab = removeChatTab(threadId); toast.success(t("chat_deleted") || "Chat deleted successfully"); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); @@ -170,6 +170,10 @@ export function AllSharedChatsSidebarContent({ if (currentChatId === threadId) { onOpenChange(false); setTimeout(() => { + if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) { + router.push(fallbackTab.chatUrl); + return; + } router.push(`/dashboard/${searchSpaceId}/new-chat`); }, 250); } diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 09f603f0f..2d3cbbe53 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -7,16 +7,15 @@ import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; -import { MarkdownViewer } from "@/components/markdown-viewer"; import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters"; import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom"; import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; +import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms"; import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom"; import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms"; -import { openDocumentTabAtom } from "@/atoms/tabs/tabs.atom"; import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog"; import type { DocumentNodeDoc } from "@/components/documents/DocumentNode"; import type { FolderDisplay } from "@/components/documents/FolderNode"; @@ -35,22 +34,13 @@ import { } from "@/components/ui/alert-dialog"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; -import { - Drawer, - DrawerContent, - DrawerHandle, - DrawerHeader, - DrawerTitle, -} from "@/components/ui/drawer"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useMediaQuery } from "@/hooks/use-media-query"; -import { useIsMobile } from "@/hooks/use-mobile"; import { foldersApiService } from "@/lib/apis/folders-api.service"; -import { documentsApiService } from "@/lib/apis/documents-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; import { queries } from "@/zero/queries/index"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; @@ -95,12 +85,10 @@ export function DocumentsSidebar({ const searchSpaceId = Number(params.search_space_id); const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom); const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom); - const openDocumentTab = useSetAtom(openDocumentTabAtom); + const openEditorPanel = useSetAtom(openEditorPanelAtom); const { data: connectors } = useAtomValue(connectorsAtom); const connectorCount = connectors?.length ?? 0; - const isMobileLayout = useIsMobile(); - const [search, setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search, 250); const [activeTypes, setActiveTypes] = useState([]); @@ -374,31 +362,6 @@ export function DocumentsSidebar({ [] ); - // Document popup viewer state (for tree view "Open" and mobile preview) - const [viewingDoc, setViewingDoc] = useState(null); - const [viewingContent, setViewingContent] = useState(""); - const [viewingLoading, setViewingLoading] = useState(false); - - const handleViewDocumentPopup = useCallback(async (doc: DocumentNodeDoc) => { - setViewingDoc(doc); - setViewingLoading(true); - try { - const fullDoc = await documentsApiService.getDocument({ id: doc.id }); - setViewingContent(fullDoc.content); - } catch (err) { - console.error("[DocumentsSidebar] Failed to fetch document content:", err); - setViewingContent("Failed to load document content."); - } finally { - setViewingLoading(false); - } - }, []); - - const handleCloseViewer = useCallback(() => { - setViewingDoc(null); - setViewingContent(""); - setViewingLoading(false); - }, []); - const handleToggleChatMention = useCallback( (doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => { if (isMentioned) { @@ -716,24 +679,18 @@ export function DocumentsSidebar({ onCreateFolder={handleCreateFolder} searchQuery={debouncedSearch.trim() || undefined} onPreviewDocument={(doc) => { - if (isMobileLayout) { - handleViewDocumentPopup(doc); - } else { - openDocumentTab({ - documentId: doc.id, - searchSpaceId, - title: doc.title, - }); - } + openEditorPanel({ + documentId: doc.id, + searchSpaceId, + title: doc.title, + }); }} onEditDocument={(doc) => { - if (!isMobileLayout) { - openDocumentTab({ - documentId: doc.id, - searchSpaceId, - title: doc.title, - }); - } + openEditorPanel({ + documentId: doc.id, + searchSpaceId, + title: doc.title, + }); }} onDeleteDocument={(doc) => handleDeleteDocument(doc.id)} onMoveDocument={handleMoveDocument} @@ -761,26 +718,6 @@ export function DocumentsSidebar({ onConfirm={handleCreateFolderConfirm} /> - !open && handleCloseViewer()}> - - - - - {viewingDoc?.title} - - -
- {viewingLoading ? ( -
- -
- ) : ( - - )} -
-
-
- !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)} diff --git a/surfsense_web/components/layout/ui/tabs/TabBar.tsx b/surfsense_web/components/layout/ui/tabs/TabBar.tsx index c163d3e3f..dc346b6cb 100644 --- a/surfsense_web/components/layout/ui/tabs/TabBar.tsx +++ b/surfsense_web/components/layout/ui/tabs/TabBar.tsx @@ -1,7 +1,7 @@ "use client"; import { useAtomValue, useSetAtom } from "jotai"; -import { FileText, MessageSquare, Plus, X } from "lucide-react"; +import { Plus, X } from "lucide-react"; import { useCallback, useEffect, useRef } from "react"; import { activeTabIdAtom, @@ -65,7 +65,6 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) { > {tabs.map((tab) => { const isActive = tab.id === activeTabId; - const Icon = tab.type === "document" ? FileText : MessageSquare; return (