mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-21 18:55:16 +02:00
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:
parent
8b625754c1
commit
3cdfe8b5b6
3 changed files with 139 additions and 57 deletions
|
|
@ -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<SlideoutPanel>(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
|
|||
<SearchSpaceSettingsDialog searchSpaceId={Number(searchSpaceId)} />
|
||||
<UserSettingsDialog />
|
||||
<TeamDialog searchSpaceId={Number(searchSpaceId)} />
|
||||
<AnnouncementsDialog />
|
||||
|
||||
{/* Agent action log + revert sheet */}
|
||||
<ActionLogSheet />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -101,7 +159,7 @@ export function TabBar({
|
|||
data-tab-id={tab.id}
|
||||
onClick={() => 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({
|
|||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0 pr-2">
|
||||
{onNewChat && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
className="flex h-8 w-8 items-center justify-center shrink-0 rounded-md text-muted-foreground transition-all duration-150 hover:text-muted-foreground hover:bg-muted/40"
|
||||
title="New Chat"
|
||||
<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"
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
className="flex h-8 w-8 items-center justify-center shrink-0 rounded-md text-muted-foreground transition-all duration-150 hover:text-muted-foreground hover:bg-muted/40"
|
||||
title="New Chat"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{rightActions}
|
||||
</div>
|
||||
{rightActions ? (
|
||||
<div className="flex items-center gap-0.5 shrink-0 pr-2">{rightActions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue