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

This commit is contained in:
Anish Sarkar 2026-05-03 18:36:23 +05:30
parent 8b625754c1
commit 3cdfe8b5b6
3 changed files with 139 additions and 57 deletions

View file

@ -36,14 +36,16 @@ const initialState: TabsState = {
// Prevent race conditions where route-sync recreates a just-deleted chat tab. // Prevent race conditions where route-sync recreates a just-deleted chat tab.
const deletedChatIdsAtom = atom<Set<number>>(new Set<number>()); const deletedChatIdsAtom = atom<Set<number>>(new Set<number>());
const sessionStorageAdapter = createJSONStorage<TabsState>( // Persist tabs in localStorage so they survive a hard refresh and let the user
() => (typeof window !== "undefined" ? sessionStorage : undefined) as Storage // keep tabs open across multiple search spaces (browser-like behavior).
const localStorageAdapter = createJSONStorage<TabsState>(
() => (typeof window !== "undefined" ? localStorage : undefined) as Storage
); );
export const tabsStateAtom = atomWithStorage<TabsState>( export const tabsStateAtom = atomWithStorage<TabsState>(
"surfsense:tabs", "surfsense:tabs",
initialState, initialState,
sessionStorageAdapter, localStorageAdapter,
{ getOnInit: true } { getOnInit: true }
); );
@ -72,7 +74,17 @@ export const syncChatTabAtom = atom(
( (
get, get,
set, 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)) { if (chatId && get(deletedChatIdsAtom).has(chatId)) {
return; return;
@ -87,20 +99,32 @@ export const syncChatTabAtom = atom(
...state, ...state,
activeTabId: tabId, activeTabId: tabId,
tabs: state.tabs.map((t) => 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; return;
} }
// If navigating to a new chat (no chatId), ensure there's a "new chat" tab // If navigating to a new chat (no chatId), ensure there's a "new chat" tab
// scoped to the current search space.
if (!chatId) { if (!chatId) {
const hasNewChatTab = state.tabs.some((t) => t.id === "chat-new"); const hasNewChatTab = state.tabs.some((t) => t.id === "chat-new");
if (hasNewChatTab) { 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 { } else {
set(tabsStateAtom, { set(tabsStateAtom, {
tabs: [...state.tabs, INITIAL_CHAT_TAB], tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, searchSpaceId, chatUrl }],
activeTabId: "chat-new", activeTabId: "chat-new",
}); });
} }
@ -115,6 +139,7 @@ export const syncChatTabAtom = atom(
title: title || `Chat ${chatId}`, title: title || `Chat ${chatId}`,
chatId, chatId,
chatUrl, chatUrl,
searchSpaceId,
}; };
let updatedTabs: Tab[]; let updatedTabs: Tab[];

View file

@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; 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 { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useTheme } from "next-themes"; 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 { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { import {
announcementsDialogAtom,
searchSpaceSettingsDialogAtom, searchSpaceSettingsDialogAtom,
teamDialogAtom, teamDialogAtom,
userSettingsDialogAtom, userSettingsDialogAtom,
} from "@/atoms/settings/settings-dialog.atoms"; } from "@/atoms/settings/settings-dialog.atoms";
import { import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
removeChatTabAtom,
resetTabsAtom,
syncChatTabAtom,
type Tab,
} from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { ActionLogSheet } from "@/components/agent-action-log/action-log-sheet"; 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 { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
import { TeamDialog } from "@/components/settings/team-dialog"; import { TeamDialog } from "@/components/settings/team-dialog";
import { UserSettingsDialog } from "@/components/settings/user-settings-dialog"; import { UserSettingsDialog } from "@/components/settings/user-settings-dialog";
@ -105,7 +102,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const currentThreadState = useAtomValue(currentThreadAtom); const currentThreadState = useAtomValue(currentThreadAtom);
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom); const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
const syncChatTab = useSetAtom(syncChatTabAtom); const syncChatTab = useSetAtom(syncChatTabAtom);
const resetTabs = useSetAtom(resetTabsAtom);
const removeChatTab = useSetAtom(removeChatTabAtom); const removeChatTab = useSetAtom(removeChatTabAtom);
// Key used to force-remount the page component (e.g. after deleting the active chat // 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) // 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<SlideoutPanel>(null); const [activeSlideoutPanel, setActiveSlideoutPanel] = useState<SlideoutPanel>(null);
const isInboxSidebarOpen = activeSlideoutPanel === "inbox"; const isInboxSidebarOpen = activeSlideoutPanel === "inbox";
const isAnnouncementsSidebarOpen = activeSlideoutPanel === "announcements";
// Documents sidebar state (shared atom so Composer can toggle it) // Documents sidebar state (shared atom so Composer can toggle it)
const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom); const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom);
@ -252,16 +247,16 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false); const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false);
const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false); const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false);
// Reset transient slide-out panels and tabs when switching search spaces. // Reset transient slide-out panels when switching search spaces.
// Use a ref to skip the initial mount — only reset when the space actually changes. // Tabs intentionally persist across spaces — opening tabs from multiple
// search spaces is a supported flow (browser-tab semantics).
const prevSearchSpaceIdRef = useRef(searchSpaceId); const prevSearchSpaceIdRef = useRef(searchSpaceId);
useEffect(() => { useEffect(() => {
if (prevSearchSpaceIdRef.current !== searchSpaceId) { if (prevSearchSpaceIdRef.current !== searchSpaceId) {
prevSearchSpaceIdRef.current = searchSpaceId; prevSearchSpaceIdRef.current = searchSpaceId;
setActiveSlideoutPanel(null); setActiveSlideoutPanel(null);
resetTabs();
} }
}, [searchSpaceId, resetTabs]); }, [searchSpaceId]);
const searchSpaces: SearchSpace[] = useMemo(() => { const searchSpaces: SearchSpace[] = useMemo(() => {
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return []; 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. // Avoid overwriting live SSE-updated tab titles with fallback values.
title: chatId ? (thread?.title ?? undefined) : "New Chat", title: chatId ? (thread?.title ?? undefined) : "New Chat",
chatUrl, chatUrl,
searchSpaceId: Number(searchSpaceId),
}); });
}, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]); }, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]);
@ -347,6 +343,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
}, [threadsData, searchSpaceId]); }, [threadsData, searchSpaceId]);
// Navigation items // 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( const navItems: NavItem[] = useMemo(
() => () =>
( (
@ -366,24 +365,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
isActive: isDocumentsSidebarOpen, isActive: isDocumentsSidebarOpen,
} }
: null, : null,
{
title: "Announcements",
url: "#announcements",
icon: Megaphone,
isActive: isAnnouncementsSidebarOpen,
badge:
announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
},
] as (NavItem | null)[] ] as (NavItem | null)[]
).filter((item): item is NavItem => item !== null), ).filter((item): item is NavItem => item !== null),
[ [isMobile, isInboxSidebarOpen, isDocumentsSidebarOpen, totalUnreadCount]
isMobile,
isInboxSidebarOpen,
isDocumentsSidebarOpen,
totalUnreadCount,
isAnnouncementsSidebarOpen,
announcementUnreadCount,
]
); );
// Handlers // Handlers
@ -401,11 +385,16 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom); const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom); const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
const setTeamDialogOpen = useSetAtom(teamDialogAtom); const setTeamDialogOpen = useSetAtom(teamDialogAtom);
const setAnnouncementsDialog = useSetAtom(announcementsDialogAtom);
const handleUserSettings = useCallback(() => { const handleUserSettings = useCallback(() => {
setUserSettingsDialog({ open: true, initialTab: "profile" }); setUserSettingsDialog({ open: true, initialTab: "profile" });
}, [setUserSettingsDialog]); }, [setUserSettingsDialog]);
const handleAnnouncements = useCallback(() => {
setAnnouncementsDialog(true);
}, [setAnnouncementsDialog]);
const handleSearchSpaceSettings = useCallback( const handleSearchSpaceSettings = useCallback(
(_space: SearchSpace) => { (_space: SearchSpace) => {
setSearchSpaceSettingsDialog({ open: true, initialTab: "general" }); setSearchSpaceSettingsDialog({ open: true, initialTab: "general" });
@ -519,10 +508,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
} }
return; return;
} }
if (item.url === "#announcements") {
setActiveSlideoutPanel((prev) => (prev === "announcements" ? null : "announcements"));
return;
}
router.push(item.url); router.push(item.url);
}, },
[router, isMobile, isDocumentsSidebarOpen, setIsDocumentsSidebarOpen, setIsRightPanelCollapsed] [router, isMobile, isDocumentsSidebarOpen, setIsDocumentsSidebarOpen, setIsRightPanelCollapsed]
@ -714,6 +699,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
onSettings={handleSettings} onSettings={handleSettings}
onManageMembers={handleManageMembers} onManageMembers={handleManageMembers}
onUserSettings={handleUserSettings} onUserSettings={handleUserSettings}
onAnnouncements={handleAnnouncements}
announcementUnreadCount={announcementUnreadCount}
onLogout={handleLogout} onLogout={handleLogout}
theme={theme} theme={theme}
setTheme={setTheme} setTheme={setTheme}
@ -901,6 +888,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
<SearchSpaceSettingsDialog searchSpaceId={Number(searchSpaceId)} /> <SearchSpaceSettingsDialog searchSpaceId={Number(searchSpaceId)} />
<UserSettingsDialog /> <UserSettingsDialog />
<TeamDialog searchSpaceId={Number(searchSpaceId)} /> <TeamDialog searchSpaceId={Number(searchSpaceId)} />
<AnnouncementsDialog />
{/* Agent action log + revert sheet */} {/* Agent action log + revert sheet */}
<ActionLogSheet /> <ActionLogSheet />

View file

@ -20,6 +20,19 @@ interface TabBarProps {
className?: string; 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({ export function TabBar({
onTabSwitch, onTabSwitch,
onNewChat, onNewChat,
@ -53,7 +66,56 @@ export function TabBar({
[closeTab, onTabSwitch] [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(() => { useEffect(() => {
if (!scrollRef.current || !activeTabId) return; if (!scrollRef.current || !activeTabId) return;
const scroller = scrollRef.current; const scroller = scrollRef.current;
@ -75,10 +137,6 @@ export function TabBar({
} }
}, [activeTabId]); }, [activeTabId]);
// Keep action slots visible even with one/no tabs
const hasAuxActions = !!leftActions || !!rightActions || !!onNewChat;
if (tabs.length <= 1 && !hasAuxActions) return null;
return ( return (
<div <div
className={cn( className={cn(
@ -101,7 +159,7 @@ export function TabBar({
data-tab-id={tab.id} data-tab-id={tab.id}
onClick={() => handleTabClick(tab)} onClick={() => handleTabClick(tab)}
className={cn( 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 isActive
? "bg-muted/60 text-foreground" ? "bg-muted/60 text-foreground"
: "bg-transparent text-muted-foreground hover:bg-muted/30 hover:text-foreground" : "bg-transparent text-muted-foreground hover:bg-muted/30 hover:text-foreground"
@ -133,9 +191,17 @@ export function TabBar({
</button> </button>
); );
})} })}
</div>
<div className="flex items-center gap-0.5 shrink-0 pr-2">
{onNewChat && ( {onNewChat && (
<div
className={cn(
// Solid bg + soft left-fade so tabs scrolling underneath the
// + button get visually masked into the bar's background —
// 1:1 port of opencode's `> .sticky` rule in tabs.css.
"sticky right-0 z-10 flex h-full shrink-0 items-center bg-main-panel pl-3 pr-1",
"before:content-[''] before:absolute before:inset-y-0 before:-left-4 before:w-4 before:pointer-events-none",
"before:bg-gradient-to-r before:from-transparent before:to-main-panel"
)}
>
<button <button
type="button" type="button"
onClick={onNewChat} onClick={onNewChat}
@ -144,9 +210,12 @@ export function TabBar({
> >
<Plus className="size-4" /> <Plus className="size-4" />
</button> </button>
)}
{rightActions}
</div> </div>
)}
</div>
{rightActions ? (
<div className="flex items-center gap-0.5 shrink-0 pr-2">{rightActions}</div>
) : null}
</div> </div>
); );
} }