From 3cdfe8b5b68f457bcda1c79d1952f337c8abae8a Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 3 May 2026 18:36:23 +0530 Subject: [PATCH] feat(tabs): persist tabs in localStorage for improved user experience across sessions; enhance syncChatTabAtom to include searchSpaceId; update LayoutDataProvider to support announcements dialog and clean up unused imports --- surfsense_web/atoms/tabs/tabs.atom.ts | 39 +++++-- .../layout/providers/LayoutDataProvider.tsx | 56 ++++------ .../components/layout/ui/tabs/TabBar.tsx | 101 +++++++++++++++--- 3 files changed, 139 insertions(+), 57 deletions(-) diff --git a/surfsense_web/atoms/tabs/tabs.atom.ts b/surfsense_web/atoms/tabs/tabs.atom.ts index 22cc5373a..3bb06c8e3 100644 --- a/surfsense_web/atoms/tabs/tabs.atom.ts +++ b/surfsense_web/atoms/tabs/tabs.atom.ts @@ -36,14 +36,16 @@ const initialState: TabsState = { // 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 +// Persist tabs in localStorage so they survive a hard refresh and let the user +// keep tabs open across multiple search spaces (browser-like behavior). +const localStorageAdapter = createJSONStorage( + () => (typeof window !== "undefined" ? localStorage : undefined) as Storage ); export const tabsStateAtom = atomWithStorage( "surfsense:tabs", initialState, - sessionStorageAdapter, + localStorageAdapter, { getOnInit: true } ); @@ -72,7 +74,17 @@ export const syncChatTabAtom = atom( ( get, set, - { chatId, title, chatUrl }: { chatId: number | null; title?: string; chatUrl?: string } + { + chatId, + title, + chatUrl, + searchSpaceId, + }: { + chatId: number | null; + title?: string; + chatUrl?: string; + searchSpaceId: number; + } ) => { if (chatId && get(deletedChatIdsAtom).has(chatId)) { return; @@ -87,20 +99,32 @@ export const syncChatTabAtom = atom( ...state, activeTabId: tabId, tabs: state.tabs.map((t) => - t.id === tabId ? { ...t, title: title || t.title, chatUrl: chatUrl || t.chatUrl } : t + t.id === tabId + ? { + ...t, + title: title || t.title, + chatUrl: chatUrl || t.chatUrl, + searchSpaceId: searchSpaceId ?? t.searchSpaceId, + } + : t ), }); return; } // If navigating to a new chat (no chatId), ensure there's a "new chat" tab + // scoped to the current search space. if (!chatId) { const hasNewChatTab = state.tabs.some((t) => t.id === "chat-new"); if (hasNewChatTab) { - set(tabsStateAtom, { ...state, activeTabId: "chat-new" }); + set(tabsStateAtom, { + ...state, + activeTabId: "chat-new", + tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, searchSpaceId, chatUrl } : t)), + }); } else { set(tabsStateAtom, { - tabs: [...state.tabs, INITIAL_CHAT_TAB], + tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, searchSpaceId, chatUrl }], activeTabId: "chat-new", }); } @@ -115,6 +139,7 @@ export const syncChatTabAtom = atom( title: title || `Chat ${chatId}`, chatId, chatUrl, + searchSpaceId, }; let updatedTabs: Tab[]; diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index d70a7ade4..c125ff0eb 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react"; +import { AlertTriangle, Inbox, SquareLibrary } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; @@ -15,18 +15,15 @@ import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom"; import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { + announcementsDialogAtom, searchSpaceSettingsDialogAtom, teamDialogAtom, userSettingsDialogAtom, } from "@/atoms/settings/settings-dialog.atoms"; -import { - removeChatTabAtom, - resetTabsAtom, - syncChatTabAtom, - type Tab, -} from "@/atoms/tabs/tabs.atom"; +import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { ActionLogSheet } from "@/components/agent-action-log/action-log-sheet"; +import { AnnouncementsDialog } from "@/components/announcements/AnnouncementsDialog"; import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog"; import { TeamDialog } from "@/components/settings/team-dialog"; import { UserSettingsDialog } from "@/components/settings/user-settings-dialog"; @@ -105,7 +102,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const currentThreadState = useAtomValue(currentThreadAtom); const resetCurrentThread = useSetAtom(resetCurrentThreadAtom); const syncChatTab = useSetAtom(syncChatTabAtom); - const resetTabs = useSetAtom(resetTabsAtom); const removeChatTab = useSetAtom(removeChatTabAtom); // Key used to force-remount the page component (e.g. after deleting the active chat @@ -132,11 +128,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid }); // Unified slide-out panel state (only one can be open at a time) - type SlideoutPanel = "inbox" | "shared" | "private" | "announcements" | null; + type SlideoutPanel = "inbox" | "shared" | "private" | null; const [activeSlideoutPanel, setActiveSlideoutPanel] = useState(null); const isInboxSidebarOpen = activeSlideoutPanel === "inbox"; - const isAnnouncementsSidebarOpen = activeSlideoutPanel === "announcements"; // Documents sidebar state (shared atom so Composer can toggle it) const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom); @@ -252,16 +247,16 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false); const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false); - // Reset transient slide-out panels and tabs when switching search spaces. - // Use a ref to skip the initial mount — only reset when the space actually changes. + // Reset transient slide-out panels when switching search spaces. + // Tabs intentionally persist across spaces — opening tabs from multiple + // search spaces is a supported flow (browser-tab semantics). const prevSearchSpaceIdRef = useRef(searchSpaceId); useEffect(() => { if (prevSearchSpaceIdRef.current !== searchSpaceId) { prevSearchSpaceIdRef.current = searchSpaceId; setActiveSlideoutPanel(null); - resetTabs(); } - }, [searchSpaceId, resetTabs]); + }, [searchSpaceId]); const searchSpaces: SearchSpace[] = useMemo(() => { if (!searchSpacesData || !Array.isArray(searchSpacesData)) return []; @@ -313,6 +308,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid // Avoid overwriting live SSE-updated tab titles with fallback values. title: chatId ? (thread?.title ?? undefined) : "New Chat", chatUrl, + searchSpaceId: Number(searchSpaceId), }); }, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]); @@ -347,6 +343,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid }, [threadsData, searchSpaceId]); // Navigation items + // Inbox is rendered explicitly below "New chat" in the sidebar (it is also + // surfaced in the icon rail's collapsed mode via this list). Announcements + // has been moved to the avatar dropdown and is no longer a nav item. const navItems: NavItem[] = useMemo( () => ( @@ -366,24 +365,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid isActive: isDocumentsSidebarOpen, } : null, - { - title: "Announcements", - url: "#announcements", - icon: Megaphone, - isActive: isAnnouncementsSidebarOpen, - badge: - announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined, - }, ] as (NavItem | null)[] ).filter((item): item is NavItem => item !== null), - [ - isMobile, - isInboxSidebarOpen, - isDocumentsSidebarOpen, - totalUnreadCount, - isAnnouncementsSidebarOpen, - announcementUnreadCount, - ] + [isMobile, isInboxSidebarOpen, isDocumentsSidebarOpen, totalUnreadCount] ); // Handlers @@ -401,11 +385,16 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom); const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom); const setTeamDialogOpen = useSetAtom(teamDialogAtom); + const setAnnouncementsDialog = useSetAtom(announcementsDialogAtom); const handleUserSettings = useCallback(() => { setUserSettingsDialog({ open: true, initialTab: "profile" }); }, [setUserSettingsDialog]); + const handleAnnouncements = useCallback(() => { + setAnnouncementsDialog(true); + }, [setAnnouncementsDialog]); + const handleSearchSpaceSettings = useCallback( (_space: SearchSpace) => { setSearchSpaceSettingsDialog({ open: true, initialTab: "general" }); @@ -519,10 +508,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid } return; } - if (item.url === "#announcements") { - setActiveSlideoutPanel((prev) => (prev === "announcements" ? null : "announcements")); - return; - } router.push(item.url); }, [router, isMobile, isDocumentsSidebarOpen, setIsDocumentsSidebarOpen, setIsRightPanelCollapsed] @@ -714,6 +699,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid onSettings={handleSettings} onManageMembers={handleManageMembers} onUserSettings={handleUserSettings} + onAnnouncements={handleAnnouncements} + announcementUnreadCount={announcementUnreadCount} onLogout={handleLogout} theme={theme} setTheme={setTheme} @@ -901,6 +888,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid + {/* Agent action log + revert sheet */} diff --git a/surfsense_web/components/layout/ui/tabs/TabBar.tsx b/surfsense_web/components/layout/ui/tabs/TabBar.tsx index c6f892034..f3c88bf6a 100644 --- a/surfsense_web/components/layout/ui/tabs/TabBar.tsx +++ b/surfsense_web/components/layout/ui/tabs/TabBar.tsx @@ -20,6 +20,19 @@ interface TabBarProps { className?: string; } +// Pure scroll-target calculation (port of opencode's nextTabListScrollLeft). +// - When the list shrinks (a tab was closed), do not move the scroll. +// - When the list overflows after growing, snap to the right edge so the new tab is visible. +function nextTabListScrollLeft(input: { + prevScrollWidth: number; + scrollWidth: number; + clientWidth: number; +}) { + if (input.scrollWidth <= input.prevScrollWidth) return; + if (input.scrollWidth <= input.clientWidth) return; + return input.scrollWidth - input.clientWidth; +} + export function TabBar({ onTabSwitch, onNewChat, @@ -53,7 +66,56 @@ export function TabBar({ [closeTab, onTabSwitch] ); - // Keep active tab visible with minimal scroll shift. + // React to tab list growth (port of opencode's createFileTabListSync). + // Uses a MutationObserver instead of a tab-id effect so the scroll catches + // the moment the new tab is added to the DOM, not after activation lands. + // Also remaps vertical wheel motion to horizontal scroll. + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + + let prevScrollWidth = el.scrollWidth; + let frame: number | undefined; + + const update = () => { + const left = nextTabListScrollLeft({ + prevScrollWidth, + scrollWidth: el.scrollWidth, + clientWidth: el.clientWidth, + }); + if (left !== undefined) { + el.scrollTo({ left, behavior: "smooth" }); + } + prevScrollWidth = el.scrollWidth; + }; + + const schedule = () => { + if (frame !== undefined) cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + frame = undefined; + update(); + }); + }; + + const onWheel = (e: WheelEvent) => { + if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; + el.scrollLeft += e.deltaY > 0 ? 50 : -50; + e.preventDefault(); + }; + + el.addEventListener("wheel", onWheel, { passive: false }); + const observer = new MutationObserver(schedule); + observer.observe(el, { childList: true }); + + return () => { + el.removeEventListener("wheel", onWheel); + observer.disconnect(); + if (frame !== undefined) cancelAnimationFrame(frame); + }; + }, []); + + // When the user activates a tab that's currently off-screen (e.g. clicked + // from the sidebar), nudge the scroller minimally so the active tab is in view. useEffect(() => { if (!scrollRef.current || !activeTabId) return; const scroller = scrollRef.current; @@ -75,10 +137,6 @@ export function TabBar({ } }, [activeTabId]); - // Keep action slots visible even with one/no tabs - const hasAuxActions = !!leftActions || !!rightActions || !!onNewChat; - if (tabs.length <= 1 && !hasAuxActions) return null; - return (
handleTabClick(tab)} className={cn( - "group relative flex h-full w-[150px] items-center px-3 min-h-0 overflow-hidden text-[13px] font-medium rounded-md transition-all duration-150 shrink-0", + "group relative flex h-full items-center px-3 min-w-[120px] max-w-[240px] min-h-0 overflow-hidden text-[13px] font-medium rounded-md transition-all duration-150 shrink-0", isActive ? "bg-muted/60 text-foreground" : "bg-transparent text-muted-foreground hover:bg-muted/30 hover:text-foreground" @@ -133,20 +191,31 @@ export function TabBar({ ); })} -
-
{onNewChat && ( - + +
)} - {rightActions} + {rightActions ? ( +
{rightActions}
+ ) : null} ); }