From a21ece6e984286366a320a8fb25b79dc77442d7b Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:38:26 +0800 Subject: [PATCH 1/2] feat: implement useLoadMoreSentinel hook for improved infinite scrolling --- .../sections/sidebar/sidebar-list.tsx | 36 +++------- .../shared/hooks/use-load-more-sentinel.ts | 65 ++++++++++++++++++- 2 files changed, 70 insertions(+), 31 deletions(-) diff --git a/frontend/features/files/components/sections/sidebar/sidebar-list.tsx b/frontend/features/files/components/sections/sidebar/sidebar-list.tsx index bb068f3e..b5a95e47 100644 --- a/frontend/features/files/components/sections/sidebar/sidebar-list.tsx +++ b/frontend/features/files/components/sections/sidebar/sidebar-list.tsx @@ -18,6 +18,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Skeleton } from "@/components/ui/skeleton"; +import { useLoadMoreSentinel } from "@/shared/hooks/use-load-more-sentinel"; import { cn } from "@/lib/utils"; import type { FileObjectDTO } from "@/shared/api/file.types"; @@ -221,34 +222,13 @@ export function SidebarList({ const loadMoreRef = React.useRef(null); const selectedFileIDSet = React.useMemo(() => new Set(selectedFileIDs), [selectedFileIDs]); - React.useEffect(() => { - if (!hasMore || loading || loadingMore) { - return; - } - - const sentinel = loadMoreRef.current; - const viewport = scrollAreaRef.current; - if (!sentinel || !viewport) { - return; - } - - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0]; - if (entry?.isIntersecting) { - onLoadMore(); - } - }, - { - root: viewport, - rootMargin: "0px 0px 200px 0px", - threshold: 0.01, - }, - ); - - observer.observe(sentinel); - return () => observer.disconnect(); - }, [hasMore, loading, loadingMore, onLoadMore]); + useLoadMoreSentinel({ + enabled: hasMore && !loading && !loadingMore, + rootMargin: "0px 0px 200px 0px", + rootRef: scrollAreaRef, + targetRef: loadMoreRef, + onLoadMore, + }); if (!loading && items.length === 0) { return ( diff --git a/frontend/shared/hooks/use-load-more-sentinel.ts b/frontend/shared/hooks/use-load-more-sentinel.ts index 05a88965..c5755fbc 100644 --- a/frontend/shared/hooks/use-load-more-sentinel.ts +++ b/frontend/shared/hooks/use-load-more-sentinel.ts @@ -5,13 +5,25 @@ import * as React from "react"; type UseLoadMoreSentinelOptions = { enabled: boolean; rootMargin?: string; + rootRef?: React.RefObject; targetRef: React.RefObject; onLoadMore: () => void | Promise; }; +function rootMarginToPixels(rootMargin: string): number { + const values = rootMargin.trim().split(/\s+/).filter(Boolean); + const value = values.length >= 3 ? values[2] : values[0] ?? ""; + const match = value.match(/^(-?\d+(?:\.\d+)?)px$/); + if (!match) { + return 120; + } + return Number(match[1]) || 0; +} + export function useLoadMoreSentinel({ enabled, rootMargin = "120px", + rootRef, targetRef, onLoadMore, }: UseLoadMoreSentinelOptions) { @@ -31,7 +43,33 @@ export function useLoadMoreSentinel({ return; } - const root = target.parentElement?.closest("[data-sidebar-scroll-root='true']") ?? null; + const root = rootRef?.current ?? target.parentElement?.closest("[data-sidebar-scroll-root='true']") ?? null; + const marginPx = rootMarginToPixels(rootMargin); + let animationFrame: number | null = null; + let disposed = false; + + const isNearLoadMorePoint = () => { + if (root) { + return root.scrollHeight - root.scrollTop - root.clientHeight <= marginPx; + } + return target.getBoundingClientRect().top <= window.innerHeight + marginPx; + }; + + const check = () => { + animationFrame = null; + if (disposed || !isNearLoadMorePoint()) { + return; + } + void onLoadMoreRef.current(); + }; + + const scheduleCheck = () => { + if (animationFrame !== null) { + return; + } + animationFrame = window.requestAnimationFrame(check); + }; + const observer = new IntersectionObserver( (entries) => { const entry = entries[0]; @@ -43,6 +81,27 @@ export function useLoadMoreSentinel({ ); observer.observe(target); - return () => observer.disconnect(); - }, [enabled, rootMargin, targetRef]); + const scrollTarget: HTMLElement | Window = root ?? window; + scrollTarget.addEventListener("scroll", scheduleCheck, { passive: true }); + window.addEventListener("resize", scheduleCheck); + + const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleCheck); + resizeObserver?.observe(target); + if (root) { + resizeObserver?.observe(root); + } + + scheduleCheck(); + + return () => { + disposed = true; + observer.disconnect(); + scrollTarget.removeEventListener("scroll", scheduleCheck); + window.removeEventListener("resize", scheduleCheck); + resizeObserver?.disconnect(); + if (animationFrame !== null) { + window.cancelAnimationFrame(animationFrame); + } + }; + }, [enabled, rootMargin, rootRef, targetRef]); } From 0c4f6bf10654d587ca33bc72cb63128b86554f45 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:45:08 +0800 Subject: [PATCH 2/2] refactor: useSidebarConversationNavigation across navigation components --- .../components/navigation/nav-projects.tsx | 4 +-- .../components/navigation/nav-recents.tsx | 4 +-- .../components/navigation/nav-starred.tsx | 4 +-- .../hooks/use-mobile-sidebar-navigation.ts | 20 ----------- .../use-sidebar-conversation-navigation.ts | 34 +++++++++++++++++++ 5 files changed, 40 insertions(+), 26 deletions(-) delete mode 100644 frontend/features/layouts/hooks/use-mobile-sidebar-navigation.ts create mode 100644 frontend/features/layouts/hooks/use-sidebar-conversation-navigation.ts diff --git a/frontend/features/layouts/components/navigation/nav-projects.tsx b/frontend/features/layouts/components/navigation/nav-projects.tsx index 5317f1f0..f3e23338 100644 --- a/frontend/features/layouts/components/navigation/nav-projects.tsx +++ b/frontend/features/layouts/components/navigation/nav-projects.tsx @@ -86,7 +86,7 @@ import { DeleteFilesOption } from "@/shared/components/delete-files-option" import { useDialogSnapshot } from "@/shared/hooks/use-dialog-snapshot" import { useSettingsChatPreferences } from "@/features/settings/hooks/use-settings-chat-preferences" import { useLayoutActiveConversation } from "@/features/layouts/hooks/use-layout-active-conversation" -import { useMobileSidebarNavigation } from "@/features/layouts/hooks/use-mobile-sidebar-navigation" +import { useSidebarConversationNavigation } from "@/features/layouts/hooks/use-sidebar-conversation-navigation" import { SidebarConversationItem } from "@/features/layouts/components/navigation/sidebar-conversation-item" import type { SidebarConversationDeleteTarget, @@ -593,7 +593,7 @@ export function NavProjects() { const resolveErrorMessage = useLocalizedErrorMessage() const { isMobile, setOpenMobile } = useSidebar() const router = useRouter() - const onNavigate = useMobileSidebarNavigation() + const onNavigate = useSidebarConversationNavigation() const pathname = usePathname() const searchParams = useSearchParams() const activeRecentProjectID = searchParams.get("project") ?? "" diff --git a/frontend/features/layouts/components/navigation/nav-recents.tsx b/frontend/features/layouts/components/navigation/nav-recents.tsx index 85e1dd30..84c3d56c 100644 --- a/frontend/features/layouts/components/navigation/nav-recents.tsx +++ b/frontend/features/layouts/components/navigation/nav-recents.tsx @@ -39,7 +39,7 @@ import { useDialogSnapshot } from "@/shared/hooks/use-dialog-snapshot" import { useSettingsChatPreferences } from "@/features/settings/hooks/use-settings-chat-preferences" import { useLayoutActiveConversation } from "@/features/layouts/hooks/use-layout-active-conversation" import { useLayoutSidebarListFlip } from "@/features/layouts/hooks/use-layout-sidebar-list-flip" -import { useMobileSidebarNavigation } from "@/features/layouts/hooks/use-mobile-sidebar-navigation" +import { useSidebarConversationNavigation } from "@/features/layouts/hooks/use-sidebar-conversation-navigation" import type { SidebarConversationDeleteTarget, SidebarConversationRenameTarget, @@ -55,7 +55,7 @@ const RECENTS_OPEN_STORAGE_KEY = "deeix.sidebar.recents.open" export function NavRecents() { const t = useTranslations("recent") - const onNavigate = useMobileSidebarNavigation() + const onNavigate = useSidebarConversationNavigation() const router = useRouter() const activeConversationID = useLayoutActiveConversation() const { deleteFilesByDefault } = useSettingsChatPreferences() diff --git a/frontend/features/layouts/components/navigation/nav-starred.tsx b/frontend/features/layouts/components/navigation/nav-starred.tsx index a3bd9f70..c982c5eb 100644 --- a/frontend/features/layouts/components/navigation/nav-starred.tsx +++ b/frontend/features/layouts/components/navigation/nav-starred.tsx @@ -42,7 +42,7 @@ import { useDialogSnapshot } from "@/shared/hooks/use-dialog-snapshot" import { useSettingsChatPreferences } from "@/features/settings/hooks/use-settings-chat-preferences" import { useLayoutActiveConversation } from "@/features/layouts/hooks/use-layout-active-conversation" import { useLayoutSidebarListFlip } from "@/features/layouts/hooks/use-layout-sidebar-list-flip" -import { useMobileSidebarNavigation } from "@/features/layouts/hooks/use-mobile-sidebar-navigation" +import { useSidebarConversationNavigation } from "@/features/layouts/hooks/use-sidebar-conversation-navigation" import { SIDEBAR_OVERFLOW_ROW_TRANSITION } from "@/features/layouts/model/sidebar-motion" import type { SidebarConversationDeleteTarget, @@ -72,7 +72,7 @@ export function NavStarred() { const t = useTranslations("recent") const { isMobile, setOpenMobile } = useSidebar() const router = useRouter() - const onNavigate = useMobileSidebarNavigation() + const onNavigate = useSidebarConversationNavigation() const activeConversationID = useLayoutActiveConversation() const { deleteFilesByDefault } = useSettingsChatPreferences() diff --git a/frontend/features/layouts/hooks/use-mobile-sidebar-navigation.ts b/frontend/features/layouts/hooks/use-mobile-sidebar-navigation.ts deleted file mode 100644 index 4865aceb..00000000 --- a/frontend/features/layouts/hooks/use-mobile-sidebar-navigation.ts +++ /dev/null @@ -1,20 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" - -import { useSidebar } from "@/components/ui/sidebar" - -export function useMobileSidebarNavigation() { - const router = useRouter() - const { isMobile } = useSidebar() - - return React.useCallback((href: string, event: React.MouseEvent) => { - if (!isMobile) { - return - } - - event.preventDefault() - router.push(href) - }, [isMobile, router]) -} diff --git a/frontend/features/layouts/hooks/use-sidebar-conversation-navigation.ts b/frontend/features/layouts/hooks/use-sidebar-conversation-navigation.ts new file mode 100644 index 00000000..3e5a52d8 --- /dev/null +++ b/frontend/features/layouts/hooks/use-sidebar-conversation-navigation.ts @@ -0,0 +1,34 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" + +import { useSidebar } from "@/components/ui/sidebar" + +function shouldUseNativeNavigation(event: React.MouseEvent) { + const target = event.currentTarget.getAttribute("target") + return event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + Boolean(target && target !== "_self") +} + +export function useSidebarConversationNavigation() { + const router = useRouter() + const { isMobile, setOpenMobile } = useSidebar() + + return React.useCallback((href: string, event: React.MouseEvent) => { + if (shouldUseNativeNavigation(event)) { + return + } + + event.preventDefault() + router.push(href) + if (isMobile) { + setOpenMobile(false) + } + }, [isMobile, router, setOpenMobile]) +}