diff --git a/surfsense_web/atoms/tabs/tabs.atom.ts b/surfsense_web/atoms/tabs/tabs.atom.ts index 7ba115a95..bb747f032 100644 --- a/surfsense_web/atoms/tabs/tabs.atom.ts +++ b/surfsense_web/atoms/tabs/tabs.atom.ts @@ -128,6 +128,21 @@ export const updateChatTabTitleAtom = atom( (get, set, { chatId, title }: { chatId: number; title: string }) => { const state = get(tabsStateAtom); const tabId = makeChatTabId(chatId); + const hasExactTab = state.tabs.some((t) => t.id === tabId); + + // During lazy thread creation, title updates can arrive before "chat-new" + // is swapped to chat-{id}. In that case, promote the active "chat-new" tab. + if (!hasExactTab && state.activeTabId === "chat-new") { + set(tabsStateAtom, { + ...state, + activeTabId: tabId, + tabs: state.tabs.map((t) => + t.id === "chat-new" ? { ...t, id: tabId, chatId, title } : t + ), + }); + return; + } + set(tabsStateAtom, { ...state, tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, title } : t)), @@ -213,6 +228,34 @@ export const closeTabAtom = atom(null, (get, set, tabId: string) => { return remaining.find((t) => t.id === newActiveId) ?? null; }); +/** Remove a chat tab by chat ID (used when a chat is deleted). */ +export const removeChatTabAtom = atom(null, (get, set, chatId: number) => { + const state = get(tabsStateAtom); + const tabId = makeChatTabId(chatId); + const idx = state.tabs.findIndex((t) => t.id === tabId); + if (idx === -1) return null; + + const remaining = state.tabs.filter((t) => t.id !== tabId); + + // Always keep at least one tab available. + if (remaining.length === 0) { + set(tabsStateAtom, { + tabs: [INITIAL_CHAT_TAB], + activeTabId: "chat-new", + }); + return INITIAL_CHAT_TAB; + } + + let newActiveId = state.activeTabId; + if (state.activeTabId === tabId) { + const newIdx = Math.min(idx, remaining.length - 1); + newActiveId = remaining[newIdx].id; + } + + set(tabsStateAtom, { tabs: remaining, activeTabId: newActiveId }); + return remaining.find((t) => t.id === newActiveId) ?? null; +}); + /** Reset tabs when switching search spaces. */ export const resetTabsAtom = atom(null, (_get, set) => { set(tabsStateAtom, { ...initialState }); diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 57a0f89cf..f611c1861 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -20,7 +20,7 @@ import { teamDialogAtom, userSettingsDialogAtom, } from "@/atoms/settings/settings-dialog.atoms"; -import { resetTabsAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; +import { removeChatTabAtom, resetTabsAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { MorePagesDialog } from "@/components/settings/more-pages-dialog"; import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog"; @@ -103,6 +103,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const resetCurrentThread = useSetAtom(resetCurrentThreadAtom); const syncChatTab = useSetAtom(syncChatTabAtom); const resetTabs = useSetAtom(resetTabsAtom); + const removeChatTab = useSetAtom(removeChatTabAtom); // State for handling new chat navigation when router is out of sync const [pendingNewChat, setPendingNewChat] = useState(false); @@ -325,7 +326,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const thread = threadsData?.threads?.find((t) => t.id === chatId); syncChatTab({ chatId, - title: thread?.title || (chatId ? `Chat ${chatId}` : "New Chat"), + // Avoid overwriting live SSE-updated tab titles with fallback values. + title: chatId ? (thread?.title ?? undefined) : "New Chat", chatUrl, }); }, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]); @@ -637,6 +639,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid setIsDeletingChat(true); try { await deleteThread(chatToDelete.id); + removeChatTab(chatToDelete.id); queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); if (currentChatId === chatToDelete.id) { resetCurrentThread(); @@ -664,6 +667,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid currentThreadState.id, params?.chat_id, router, + removeChatTab, ]); // Rename handler diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx index b00f701c4..4a359859a 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx @@ -1,6 +1,7 @@ "use client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSetAtom } from "jotai"; import { format } from "date-fns"; import { ArchiveIcon, @@ -41,6 +42,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useLongPress } from "@/hooks/use-long-press"; import { useIsMobile } from "@/hooks/use-mobile"; +import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom"; import { deleteThread, fetchThreads, @@ -70,6 +72,7 @@ export function AllPrivateChatsSidebarContent({ const params = useParams(); const queryClient = useQueryClient(); const isMobile = useIsMobile(); + const removeChatTab = useSetAtom(removeChatTabAtom); const currentChatId = Array.isArray(params.chat_id) ? Number(params.chat_id[0]) @@ -158,6 +161,7 @@ export function AllPrivateChatsSidebarContent({ setDeletingThreadId(threadId); try { await deleteThread(threadId); + removeChatTab(threadId); toast.success(t("chat_deleted") || "Chat deleted successfully"); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); @@ -176,7 +180,7 @@ export function AllPrivateChatsSidebarContent({ setDeletingThreadId(null); } }, - [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange] + [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange, removeChatTab] ); const handleToggleArchive = useCallback( diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx index d8289d1e9..6e7828116 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx @@ -1,6 +1,7 @@ "use client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSetAtom } from "jotai"; import { format } from "date-fns"; import { ArchiveIcon, @@ -41,6 +42,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useLongPress } from "@/hooks/use-long-press"; import { useIsMobile } from "@/hooks/use-mobile"; +import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom"; import { deleteThread, fetchThreads, @@ -70,6 +72,7 @@ export function AllSharedChatsSidebarContent({ const params = useParams(); const queryClient = useQueryClient(); const isMobile = useIsMobile(); + const removeChatTab = useSetAtom(removeChatTabAtom); const currentChatId = Array.isArray(params.chat_id) ? Number(params.chat_id[0]) @@ -158,6 +161,7 @@ export function AllSharedChatsSidebarContent({ setDeletingThreadId(threadId); try { await deleteThread(threadId); + removeChatTab(threadId); toast.success(t("chat_deleted") || "Chat deleted successfully"); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); @@ -176,7 +180,7 @@ export function AllSharedChatsSidebarContent({ setDeletingThreadId(null); } }, - [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange] + [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange, removeChatTab] ); const handleToggleArchive = useCallback(