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"]) =>