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 5297e275d..75cfa4184 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 @@ -37,7 +37,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 { removeChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom"; +import { removeChatTabAtom, syncChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { EditMessageDialog, @@ -450,6 +450,7 @@ export default function NewChatPage() { const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom); const closeReportPanel = useSetAtom(closeReportPanelAtom); const closeEditorPanel = useSetAtom(closeEditorPanelAtom); + const syncChatTab = useSetAtom(syncChatTabAtom); const updateChatTabTitle = useSetAtom(updateChatTabTitleAtom); const removeChatTab = useSetAtom(removeChatTabAtom); const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom); @@ -726,9 +727,18 @@ export default function NewChatPage() { return; } if (threadDetailQuery.data?.id === activeThreadId) { - setCurrentThread(threadDetailQuery.data); + const thread = threadDetailQuery.data; + setCurrentThread(thread); + syncChatTab({ + chatId: thread.id, + title: thread.title, + chatUrl: `/dashboard/${thread.search_space_id ?? searchSpaceId}/new-chat/${thread.id}`, + searchSpaceId: thread.search_space_id ?? searchSpaceId, + visibility: thread.visibility, + hasComments: thread.has_comments ?? false, + }); } - }, [activeThreadId, threadDetailQuery.data]); + }, [activeThreadId, searchSpaceId, syncChatTab, threadDetailQuery.data]); useEffect(() => { const messagesResponse = threadMessagesQuery.data; @@ -856,6 +866,7 @@ export default function NewChatPage() { } setCurrentThreadMetadata({ id: null, + searchSpaceId: null, visibility: null, hasComments: false, }); @@ -869,6 +880,7 @@ export default function NewChatPage() { setCurrentThreadMetadata({ id: currentThread.id, + searchSpaceId: currentThread.search_space_id ?? searchSpaceId, visibility, hasComments: currentThread.has_comments ?? false, }); @@ -877,6 +889,7 @@ export default function NewChatPage() { currentThread, currentThreadState.id, currentThreadState.visibility, + searchSpaceId, setCurrentThreadMetadata, ]); diff --git a/surfsense_web/atoms/chat/current-thread.atom.ts b/surfsense_web/atoms/chat/current-thread.atom.ts index 98a554af4..9e931a9bf 100644 --- a/surfsense_web/atoms/chat/current-thread.atom.ts +++ b/surfsense_web/atoms/chat/current-thread.atom.ts @@ -4,12 +4,14 @@ import { reportPanelAtom } from "./report-panel.atom"; interface CurrentThreadState { id: number | null; + searchSpaceId: number | null; visibility: ChatVisibility | null; hasComments: boolean; } interface CurrentThreadMetadataPatch { id: number | null; + searchSpaceId?: number | null; visibility?: ChatVisibility | null; hasComments?: boolean; } @@ -22,6 +24,7 @@ interface CurrentThreadMetadataUpdate { const initialState: CurrentThreadState = { id: null, + searchSpaceId: null, visibility: null, hasComments: false, }; @@ -32,21 +35,33 @@ export const commentsEnabledAtom = atom( (get) => get(currentThreadAtom).visibility === "SEARCH_SPACE" ); -export const setThreadVisibilityAtom = atom(null, (get, set, newVisibility: ChatVisibility) => { - set(currentThreadAtom, { ...get(currentThreadAtom), visibility: newVisibility }); -}); - export const setCurrentThreadMetadataAtom = atom( null, (get, set, metadata: CurrentThreadMetadataPatch) => { const current = get(currentThreadAtom); + const isSameThread = current.id === metadata.id; set(currentThreadAtom, { ...current, id: metadata.id, - visibility: "visibility" in metadata ? (metadata.visibility ?? null) : current.visibility, + searchSpaceId: + "searchSpaceId" in metadata + ? (metadata.searchSpaceId ?? null) + : isSameThread + ? current.searchSpaceId + : null, + visibility: + "visibility" in metadata + ? (metadata.visibility ?? null) + : isSameThread + ? current.visibility + : null, hasComments: - "hasComments" in metadata ? (metadata.hasComments ?? false) : current.hasComments, + "hasComments" in metadata + ? (metadata.hasComments ?? false) + : isSameThread + ? current.hasComments + : false, }); } ); diff --git a/surfsense_web/atoms/tabs/tabs.atom.ts b/surfsense_web/atoms/tabs/tabs.atom.ts index dba459cc9..0abbf2e74 100644 --- a/surfsense_web/atoms/tabs/tabs.atom.ts +++ b/surfsense_web/atoms/tabs/tabs.atom.ts @@ -1,5 +1,6 @@ import { atom } from "jotai"; import { atomWithStorage, createJSONStorage } from "jotai/utils"; +import type { ChatVisibility } from "@/lib/chat/thread-persistence"; export type TabType = "chat" | "document"; @@ -10,6 +11,8 @@ export interface Tab { /** For chat tabs */ chatId?: number | null; chatUrl?: string; + visibility?: ChatVisibility; + hasComments?: boolean; /** For document tabs */ documentId?: number; searchSpaceId?: number; @@ -79,11 +82,15 @@ export const syncChatTabAtom = atom( title, chatUrl, searchSpaceId, + visibility, + hasComments, }: { chatId: number | null; title?: string; chatUrl?: string; searchSpaceId: number; + visibility?: ChatVisibility; + hasComments?: boolean; } ) => { if (chatId && get(deletedChatIdsAtom).has(chatId)) { @@ -105,6 +112,8 @@ export const syncChatTabAtom = atom( title: title || t.title, chatUrl: chatUrl || t.chatUrl, searchSpaceId: searchSpaceId ?? t.searchSpaceId, + ...(visibility !== undefined ? { visibility } : {}), + ...(hasComments !== undefined ? { hasComments } : {}), } : t ), @@ -140,6 +149,8 @@ export const syncChatTabAtom = atom( chatId, chatUrl, searchSpaceId, + ...(visibility !== undefined ? { visibility } : {}), + ...(hasComments !== undefined ? { hasComments } : {}), }; let updatedTabs: Tab[]; diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index ca66d13ec..46f6ec8ae 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -8,11 +8,7 @@ import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { - currentThreadAtom, - resetCurrentThreadAtom, - setCurrentThreadMetadataAtom, -} from "@/atoms/chat/current-thread.atom"; +import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom"; import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom"; @@ -45,6 +41,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; +import { useActivateChatThread } from "@/hooks/use-activate-chat-thread"; import { useAnnouncements } from "@/hooks/use-announcements"; import { useInbox } from "@/hooks/use-inbox"; import { useIsMobile } from "@/hooks/use-mobile"; @@ -98,9 +95,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom); const currentThreadState = useAtomValue(currentThreadAtom); const resetCurrentThread = useSetAtom(resetCurrentThreadAtom); - const setCurrentThreadMetadata = useSetAtom(setCurrentThreadMetadataAtom); const syncChatTab = useSetAtom(syncChatTabAtom); const removeChatTab = useSetAtom(removeChatTabAtom); + const { activateChatThread, prefetchChatThread } = useActivateChatThread(); const { mutateAsync: archiveThread } = useArchiveThread(searchSpaceId); const { mutateAsync: deleteThread } = useDeleteThread(searchSpaceId); const { mutateAsync: renameThread } = useRenameThread(searchSpaceId); @@ -309,6 +306,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid title: chatId ? (thread?.title ?? undefined) : "New Chat", chatUrl, searchSpaceId: Number(searchSpaceId), + ...(thread?.visibility !== undefined ? { visibility: thread.visibility } : {}), }); }, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]); @@ -469,12 +467,34 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const handleTabSwitch = useCallback( (tab: Tab) => { if (tab.type === "chat") { - const url = tab.chatUrl || `/dashboard/${searchSpaceId}/new-chat`; - router.push(url); + activateChatThread({ + id: tab.chatId ?? null, + title: tab.title, + url: tab.chatUrl, + searchSpaceId: tab.searchSpaceId ?? searchSpaceId, + ...(tab.visibility !== undefined ? { visibility: tab.visibility } : {}), + ...(tab.hasComments !== undefined ? { hasComments: tab.hasComments } : {}), + }); } // Document tabs are handled in-place by LayoutShell — no navigation needed }, - [router, searchSpaceId] + [activateChatThread, searchSpaceId] + ); + + const handleTabPrefetch = useCallback( + (tab: Tab) => { + if (tab.type === "chat") { + prefetchChatThread(tab.chatId); + } + }, + [prefetchChatThread] + ); + + const handleChatPrefetch = useCallback( + (chat: ChatItem) => { + prefetchChatThread(chat.id); + }, + [prefetchChatThread] ); const handleNavItemClick = useCallback( @@ -526,20 +546,15 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const handleChatSelect = useCallback( (chat: ChatItem) => { - syncChatTab({ - chatId: chat.id, - title: chat.name, - chatUrl: chat.url, - searchSpaceId: Number(searchSpaceId), - }); - setCurrentThreadMetadata({ + activateChatThread({ id: chat.id, - visibility: chat.visibility ?? "PRIVATE", - hasComments: false, + title: chat.name, + url: chat.url, + searchSpaceId, + ...(chat.visibility !== undefined ? { visibility: chat.visibility } : {}), }); - router.push(chat.url); }, - [router, searchSpaceId, setCurrentThreadMetadata, syncChatTab] + [activateChatThread, searchSpaceId] ); const handleChatDelete = useCallback((chat: ChatItem) => { @@ -611,7 +626,16 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid if (currentChatId === chatToDelete.id) { resetCurrentThread(); if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) { - router.push(fallbackTab.chatUrl); + activateChatThread({ + id: fallbackTab.chatId ?? null, + title: fallbackTab.title, + url: fallbackTab.chatUrl, + searchSpaceId: fallbackTab.searchSpaceId ?? searchSpaceId, + ...(fallbackTab.visibility !== undefined ? { visibility: fallbackTab.visibility } : {}), + ...(fallbackTab.hasComments !== undefined + ? { hasComments: fallbackTab.hasComments } + : {}), + }); } else { const isOutOfSync = currentThreadState.id !== null && !params?.chat_id; if (isOutOfSync) { @@ -639,6 +663,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid params?.chat_id, router, removeChatTab, + activateChatThread, ]); // Rename handler @@ -693,6 +718,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid activeChatId={currentChatId} onNewChat={handleNewChat} onChatSelect={handleChatSelect} + onChatPrefetch={handleChatPrefetch} onChatRename={handleChatRename} onChatDelete={handleChatDelete} onChatArchive={handleChatArchive} @@ -759,6 +785,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid onOpenChange: setIsDocumentsSidebarOpen, }} onTabSwitch={handleTabSwitch} + onTabPrefetch={handleTabPrefetch} > {children} diff --git a/surfsense_web/components/layout/ui/header/Header.tsx b/surfsense_web/components/layout/ui/header/Header.tsx index 572f61869..79839622d 100644 --- a/surfsense_web/components/layout/ui/header/Header.tsx +++ b/surfsense_web/components/layout/ui/header/Header.tsx @@ -26,6 +26,14 @@ export function Header({ mobileMenuTrigger }: HeaderProps) { const currentThreadState = useAtomValue(currentThreadAtom); const hasThread = isChatPage && !isDocumentTab && currentThreadState.id !== null; + const activeSearchSpaceId = searchSpaceId ? Number(searchSpaceId) : null; + const canRenderShareButton = + hasThread && + currentThreadState.id !== null && + currentThreadState.visibility !== null && + currentThreadState.searchSpaceId !== null && + activeSearchSpaceId !== null && + currentThreadState.searchSpaceId === activeSearchSpaceId; // Free chat pages have their own header with model selector; only render mobile trigger if (isFreePage) { @@ -37,19 +45,24 @@ export function Header({ mobileMenuTrigger }: HeaderProps) { ); } - const threadForButton: ThreadRecord | null = - hasThread && currentThreadState.id !== null && searchSpaceId - ? { - id: currentThreadState.id, - visibility: currentThreadState.visibility ?? "PRIVATE", - created_by_id: null, - search_space_id: Number(searchSpaceId), - title: "", - archived: false, - created_at: "", - updated_at: "", - } - : null; + let threadForButton: ThreadRecord | null = null; + if ( + canRenderShareButton && + currentThreadState.id !== null && + currentThreadState.visibility !== null && + currentThreadState.searchSpaceId !== null + ) { + threadForButton = { + id: currentThreadState.id, + visibility: currentThreadState.visibility, + created_by_id: null, + search_space_id: currentThreadState.searchSpaceId, + title: "", + archived: false, + created_at: "", + updated_at: "", + }; + } return (
@@ -64,7 +77,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) { {/* Right side - Actions */}
{hasThread && } - {hasThread && } + {threadForButton && }
); diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 8823f573c..1c076a254 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -117,6 +117,7 @@ interface LayoutShellProps { activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; + onChatPrefetch?: (chat: ChatItem) => void; onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; @@ -153,11 +154,13 @@ interface LayoutShellProps { onOpenChange: (open: boolean) => void; }; onTabSwitch?: (tab: Tab) => void; + onTabPrefetch?: (tab: Tab) => void; } function MainContentPanel({ isChatPage, onTabSwitch, + onTabPrefetch, onNewChat, showRightPanelExpandButton = true, showTopBorder = false, @@ -165,6 +168,7 @@ function MainContentPanel({ }: { isChatPage: boolean; onTabSwitch?: (tab: Tab) => void; + onTabPrefetch?: (tab: Tab) => void; onNewChat?: () => void; showRightPanelExpandButton?: boolean; showTopBorder?: boolean; @@ -179,6 +183,7 @@ function MainContentPanel({ > : null} className="min-w-0" @@ -223,6 +228,7 @@ export function LayoutShell({ activeChatId, onNewChat, onChatSelect, + onChatPrefetch, onChatRename, onChatDelete, onChatArchive, @@ -251,6 +257,7 @@ export function LayoutShell({ allChatsPanel, documentsPanel, onTabSwitch, + onTabPrefetch, }: LayoutShellProps) { const isMobile = useIsMobile(); const electronAPI = useElectronAPI(); @@ -305,6 +312,7 @@ export function LayoutShell({ activeChatId={activeChatId} onNewChat={onNewChat} onChatSelect={onChatSelect} + onChatPrefetch={onChatPrefetch} onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} @@ -447,6 +455,7 @@ export function LayoutShell({ activeChatId={activeChatId} onNewChat={onNewChat} onChatSelect={onChatSelect} + onChatPrefetch={onChatPrefetch} onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} @@ -551,6 +560,7 @@ export function LayoutShell({ { - const chatUrl = `/dashboard/${searchSpaceId}/new-chat/${thread.id}`; - syncChatTab({ - chatId: thread.id, - title: thread.title || "New Chat", - chatUrl, - searchSpaceId: Number(searchSpaceId), - }); - setCurrentThreadMetadata({ + activateChatThread({ id: thread.id, + title: thread.title || "New Chat", + searchSpaceId, visibility: thread.visibility, - hasComments: false, }); - router.push(chatUrl); onOpenChange(false); onCloseMobileSidebar?.(); }, - [ - router, - onOpenChange, - searchSpaceId, - onCloseMobileSidebar, - setCurrentThreadMetadata, - syncChatTab, - ] + [activateChatThread, onOpenChange, searchSpaceId, onCloseMobileSidebar] ); const handleDeleteThread = useCallback( @@ -183,8 +167,23 @@ export function AllChatsSidebarContent({ if (currentChatId === threadId) { onOpenChange(false); setTimeout(() => { - if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) { - router.push(fallbackTab.chatUrl); + if ( + fallbackTab?.type === "chat" && + fallbackTab.chatUrl && + fallbackTab.chatId !== undefined + ) { + activateChatThread({ + id: fallbackTab.chatId ?? null, + title: fallbackTab.title, + url: fallbackTab.chatUrl, + searchSpaceId: fallbackTab.searchSpaceId ?? searchSpaceId, + ...(fallbackTab.visibility !== undefined + ? { visibility: fallbackTab.visibility } + : {}), + ...(fallbackTab.hasComments !== undefined + ? { hasComments: fallbackTab.hasComments } + : {}), + }); return; } router.push(`/dashboard/${searchSpaceId}/new-chat`); @@ -197,7 +196,16 @@ export function AllChatsSidebarContent({ setDeletingThreadId(null); } }, - [deleteThread, t, currentChatId, router, onOpenChange, removeChatTab, searchSpaceId] + [ + activateChatThread, + deleteThread, + t, + currentChatId, + router, + onOpenChange, + removeChatTab, + searchSpaceId, + ] ); const handleToggleArchive = useCallback( @@ -362,8 +370,8 @@ export function AllChatsSidebarContent({ if (wasLongPress()) return; handleThreadClick(thread); }} - onMouseEnter={() => prefetchThreadData(queryClient, thread.id)} - onFocus={() => prefetchThreadData(queryClient, thread.id)} + onMouseEnter={() => prefetchChatThread(thread.id)} + onFocus={() => prefetchChatThread(thread.id)} onTouchStart={() => { pendingThreadIdRef.current = thread.id; longPressHandlers.onTouchStart(); @@ -389,8 +397,8 @@ export function AllChatsSidebarContent({ type="button" variant="ghost" onClick={() => handleThreadClick(thread)} - onMouseEnter={() => prefetchThreadData(queryClient, thread.id)} - onFocus={() => prefetchThreadData(queryClient, thread.id)} + onMouseEnter={() => prefetchChatThread(thread.id)} + onFocus={() => prefetchChatThread(thread.id)} disabled={isBusy} className={cn( "h-auto w-full justify-start gap-2 overflow-hidden px-2 py-1.5 text-left font-normal", diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index bd9a95094..89a01d0c7 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -22,6 +22,7 @@ interface MobileSidebarProps { activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; + onChatPrefetch?: (chat: ChatItem) => void; onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; @@ -69,6 +70,7 @@ export function MobileSidebar({ activeChatId, onNewChat, onChatSelect, + onChatPrefetch, onChatRename, onChatDelete, onChatArchive, @@ -152,6 +154,7 @@ export function MobileSidebar({ onOpenChange(false); }} onChatSelect={handleChatSelect} + onChatPrefetch={onChatPrefetch} onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 16d022a0f..6a4785d98 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -1,6 +1,5 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; import { CreditCard, Dot, SquarePen, Zap } from "lucide-react"; import Link from "next/link"; import { useParams } from "next/navigation"; @@ -11,7 +10,6 @@ import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; import { useIsAnonymous } from "@/contexts/anonymous-mode"; -import { prefetchThreadData } from "@/hooks/use-thread-queries"; import { cn } from "@/lib/utils"; import { SIDEBAR_MIN_WIDTH } from "../../hooks/useSidebarResize"; import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; @@ -72,6 +70,7 @@ interface SidebarProps { activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; + onChatPrefetch?: (chat: ChatItem) => void; onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; @@ -108,6 +107,7 @@ export function Sidebar({ activeChatId, onNewChat, onChatSelect, + onChatPrefetch, onChatRename, onChatDelete, onChatArchive, @@ -134,7 +134,6 @@ export function Sidebar({ collapsedHeaderContent, }: SidebarProps) { const t = useTranslations("sidebar"); - const queryClient = useQueryClient(); const [openDropdownChatId, setOpenDropdownChatId] = useState(null); // Inbox, Automations, and Documents are rendered explicitly right below @@ -296,7 +295,7 @@ export function Sidebar({ dropdownOpen={openDropdownChatId === chat.id} onDropdownOpenChange={(open) => setOpenDropdownChatId(open ? chat.id : null)} onClick={() => onChatSelect(chat)} - onPrefetch={() => prefetchThreadData(queryClient, chat.id)} + onPrefetch={() => onChatPrefetch?.(chat)} onRename={() => onChatRename?.(chat)} onArchive={() => onChatArchive?.(chat)} onDelete={() => onChatDelete?.(chat)} diff --git a/surfsense_web/components/layout/ui/tabs/TabBar.tsx b/surfsense_web/components/layout/ui/tabs/TabBar.tsx index c5fb91f4d..869c9cee2 100644 --- a/surfsense_web/components/layout/ui/tabs/TabBar.tsx +++ b/surfsense_web/components/layout/ui/tabs/TabBar.tsx @@ -15,6 +15,7 @@ import { cn } from "@/lib/utils"; interface TabBarProps { onTabSwitch?: (tab: Tab) => void; + onTabPrefetch?: (tab: Tab) => void; onNewChat?: () => void; leftActions?: React.ReactNode; rightActions?: React.ReactNode; @@ -36,6 +37,7 @@ function nextTabListScrollLeft(input: { export function TabBar({ onTabSwitch, + onTabPrefetch, onNewChat, leftActions, rightActions, @@ -71,6 +73,15 @@ export function TabBar({ [activeTabId, switchTab, onTabSwitch] ); + const handleTabPrefetch = useCallback( + (tab: Tab) => { + if (tab.type === "chat") { + onTabPrefetch?.(tab); + } + }, + [onTabPrefetch] + ); + const handleTabClose = useCallback( (e: React.MouseEvent, tabId: string) => { e.stopPropagation(); @@ -195,7 +206,11 @@ export function TabBar({ type="button" variant="ghost" onClick={() => handleTabClick(tab)} - onMouseEnter={() => setHoveredTabIndex(index)} + onMouseEnter={() => { + setHoveredTabIndex(index); + handleTabPrefetch(tab); + }} + onFocus={() => handleTabPrefetch(tab)} onMouseLeave={() => setHoveredTabIndex(null)} className={cn( "h-full w-full justify-start overflow-hidden px-3 text-left text-[13px] font-medium transition-colors duration-150", diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index f46656de8..5e5e3fe19 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -77,8 +77,9 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS }); const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0; - // Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop - const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE"; + // Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop. + // Unknown visibility should not be presented as private while thread detail is still resolving. + const currentVisibility = currentThreadState.visibility ?? thread?.visibility; const handleVisibilityChange = useCallback( async (newVisibility: ChatVisibility) => { @@ -120,7 +121,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS }, [thread, createSnapshot, queryClient]); // Don't show if no thread (new chat that hasn't been created yet) - if (!thread) { + if (!thread || currentVisibility === undefined) { return null; } diff --git a/surfsense_web/hooks/use-activate-chat-thread.ts b/surfsense_web/hooks/use-activate-chat-thread.ts new file mode 100644 index 000000000..2367093b2 --- /dev/null +++ b/surfsense_web/hooks/use-activate-chat-thread.ts @@ -0,0 +1,79 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { useSetAtom } from "jotai"; +import { useRouter } from "next/navigation"; +import { useCallback } from "react"; +import { setCurrentThreadMetadataAtom } from "@/atoms/chat/current-thread.atom"; +import { syncChatTabAtom } from "@/atoms/tabs/tabs.atom"; +import type { ChatVisibility } from "@/lib/chat/thread-persistence"; +import { prefetchThreadData } from "./use-thread-queries"; + +interface ActivateChatThreadInput { + id: number | null; + title?: string; + url?: string; + searchSpaceId: number | string; + visibility?: ChatVisibility; + hasComments?: boolean; +} + +function getSearchSpaceId(searchSpaceId: number | string): number { + const parsed = + typeof searchSpaceId === "number" ? searchSpaceId : Number.parseInt(searchSpaceId, 10); + return Number.isNaN(parsed) ? 0 : parsed; +} + +function getChatUrl(searchSpaceId: number | string, threadId: number | null): string { + return threadId + ? `/dashboard/${searchSpaceId}/new-chat/${threadId}` + : `/dashboard/${searchSpaceId}/new-chat`; +} + +export function useActivateChatThread() { + const router = useRouter(); + const queryClient = useQueryClient(); + const syncChatTab = useSetAtom(syncChatTabAtom); + const setCurrentThreadMetadata = useSetAtom(setCurrentThreadMetadataAtom); + + const prefetchChatThread = useCallback( + (threadId: number | null | undefined) => { + if (typeof threadId === "number" && threadId > 0) { + prefetchThreadData(queryClient, threadId); + } + }, + [queryClient] + ); + + const activateChatThread = useCallback( + ({ id, title, url, searchSpaceId, visibility, hasComments }: ActivateChatThreadInput) => { + const numericSearchSpaceId = getSearchSpaceId(searchSpaceId); + const chatUrl = url ?? getChatUrl(searchSpaceId, id); + + syncChatTab({ + chatId: id, + title: id ? title : (title ?? "New Chat"), + chatUrl, + searchSpaceId: numericSearchSpaceId, + ...(visibility !== undefined ? { visibility } : {}), + ...(hasComments !== undefined ? { hasComments } : {}), + }); + + setCurrentThreadMetadata({ + id, + searchSpaceId: numericSearchSpaceId, + ...(visibility !== undefined ? { visibility } : {}), + ...(hasComments !== undefined ? { hasComments } : {}), + }); + + if (id) { + prefetchThreadData(queryClient, id); + } + + router.push(chatUrl); + }, + [queryClient, router, setCurrentThreadMetadata, syncChatTab] + ); + + return { activateChatThread, prefetchChatThread }; +} diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 200eb0eeb..6f8885d7e 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -19,11 +19,8 @@ function stableEntries(obj: Record | null | undefined): unknown export const cacheKeys = { // New chat threads (assistant-ui) threads: { - list: (searchSpaceId: number) => ["threads", searchSpaceId] as const, detail: (threadId: number) => ["threads", "detail", threadId] as const, messages: (threadId: number) => ["threads", "messages", threadId] as const, - search: (searchSpaceId: number, query: string) => - ["threads", "search", searchSpaceId, query] as const, }, documents: { globalQueryParams: (queries: GetDocumentsRequest["queryParams"]) =>