From d903a8cae3d172658eb0e3b5591b6e5983ed596c Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Wed, 4 Feb 2026 15:03:05 +0530 Subject: [PATCH] feat: add ScrollPositionPreserver component to manage scroll engagement in conversations. --- apps/x/apps/renderer/src/App.tsx | 2 + .../components/ai-elements/conversation.tsx | 220 ++++++++++-------- .../renderer/src/components/chat-sidebar.tsx | 2 + 3 files changed, 131 insertions(+), 93 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 25ab8031..ed5bdc9e 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -20,6 +20,7 @@ import { Conversation, ConversationContent, ConversationEmptyState, + ScrollPositionPreserver, } from '@/components/ai-elements/conversation'; import { Message, @@ -1821,6 +1822,7 @@ function App() { ) : (
+ {!hasConversation ? ( diff --git a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx index bba0b9e8..d9f36353 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx @@ -3,115 +3,149 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { ArrowDownIcon } from "lucide-react"; -import type { ComponentProps } from "react"; -import { useCallback, useEffect, useRef } from "react"; +import type { ComponentProps, ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; -export type ConversationProps = ComponentProps; +// Context to share scroll preservation state +interface ScrollPreservationContextValue { + registerScrollContainer: (container: HTMLElement | null) => void; + markUserEngaged: () => void; + resetEngagement: () => void; +} -export const Conversation = ({ className, ...props }: ConversationProps) => ( - -); +const ScrollPreservationContext = createContext(null); -// Threshold in pixels - if user scrolls more than this from bottom, they're considered "engaged" -const SCROLL_ENGAGEMENT_THRESHOLD = 100; +export type ConversationProps = ComponentProps & { + children?: ReactNode; +}; + +export const Conversation = ({ className, children, ...props }: ConversationProps) => { + const [scrollContainer, setScrollContainer] = useState(null); + const isUserEngagedRef = useRef(false); + const savedScrollTopRef = useRef(0); + const lastScrollHeightRef = useRef(0); + + const contextValue: ScrollPreservationContextValue = { + registerScrollContainer: (container) => { + setScrollContainer(container); + }, + markUserEngaged: () => { + // Only save position on first engagement, not on repeated calls + if (!isUserEngagedRef.current && scrollContainer) { + savedScrollTopRef.current = scrollContainer.scrollTop; + lastScrollHeightRef.current = scrollContainer.scrollHeight; + } + isUserEngagedRef.current = true; + }, + resetEngagement: () => { + isUserEngagedRef.current = false; + }, + }; + + // Watch for content changes and restore scroll position if user was engaged + useEffect(() => { + if (!scrollContainer) return; + + let rafId: number | null = null; + + const checkAndRestoreScroll = () => { + if (!isUserEngagedRef.current) return; + + const currentScrollTop = scrollContainer.scrollTop; + const currentScrollHeight = scrollContainer.scrollHeight; + const savedScrollTop = savedScrollTopRef.current; + + // If scroll position jumped significantly (auto-scroll happened) + // and scroll height also changed (content changed), restore position + if ( + Math.abs(currentScrollTop - savedScrollTop) > 50 && + currentScrollHeight !== lastScrollHeightRef.current + ) { + scrollContainer.scrollTop = savedScrollTop; + } + + lastScrollHeightRef.current = currentScrollHeight; + }; + + // Use ResizeObserver to detect content changes + const resizeObserver = new ResizeObserver(() => { + if (rafId) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(checkAndRestoreScroll); + }); + + resizeObserver.observe(scrollContainer); + + return () => { + resizeObserver.disconnect(); + if (rafId) cancelAnimationFrame(rafId); + }; + }, [scrollContainer]); + + return ( + + + {children} + + + ); +}; /** - * Component that preserves scroll position when user has scrolled away from bottom. - * Place this inside a StickToBottom context to prevent unwanted scroll jumps. + * Component that tracks scroll engagement and preserves position. + * Must be used inside Conversation component. */ export const ScrollPositionPreserver = () => { const { isAtBottom } = useStickToBottomContext(); - const scrollContainerRef = useRef(null); - const isUserEngagedRef = useRef(false); - const savedScrollTopRef = useRef(null); - const lastContentHeightRef = useRef(0); + const preservationContext = useContext(ScrollPreservationContext); + const containerFoundRef = useRef(false); - useEffect(() => { - // Find the scroll container (StickToBottom creates a scrollable element) - const findScrollContainer = () => { - // The scroll container is the element with overflow-y-auto/scroll - const containers = document.querySelectorAll('[data-stick-to-bottom-scroll-container]'); - if (containers.length > 0) { - return containers[0] as HTMLElement; - } - // Fallback: find by class pattern from the library - const fallback = document.querySelector('.use-stick-to-bottom-scroll-container'); - return fallback as HTMLElement | null; - }; + // Find and register scroll container on mount + useLayoutEffect(() => { + if (containerFoundRef.current || !preservationContext) return; - // Try to find it, the library creates it dynamically - const container = findScrollContainer(); - if (container) { - scrollContainerRef.current = container; - } - }, []); - - // Track when user scrolls away from bottom - useEffect(() => { - if (!isAtBottom) { - // User is not at bottom - they've scrolled up - isUserEngagedRef.current = true; - - // Save their current position - if (scrollContainerRef.current) { - savedScrollTopRef.current = scrollContainerRef.current.scrollTop; - lastContentHeightRef.current = scrollContainerRef.current.scrollHeight; - } - } - }, [isAtBottom]); - - // When user reaches bottom again, reset engagement - useEffect(() => { - if (isAtBottom && isUserEngagedRef.current) { - isUserEngagedRef.current = false; - savedScrollTopRef.current = null; - } - }, [isAtBottom]); - - // Use MutationObserver to detect content changes and restore position if needed - useEffect(() => { - const container = scrollContainerRef.current; - if (!container) return; - - const observer = new MutationObserver(() => { - // If user was engaged (scrolled away) and we have a saved position - if (isUserEngagedRef.current && savedScrollTopRef.current !== null) { - const currentHeight = container.scrollHeight; - const previousHeight = lastContentHeightRef.current; - - // If content height changed significantly and user was scrolled away - if (Math.abs(currentHeight - previousHeight) > 10) { - // Calculate how far from bottom they were - const distanceFromBottom = previousHeight - savedScrollTopRef.current - container.clientHeight; - - // Restore position relative to where they were - // If they were reading something in the middle, keep them there - if (distanceFromBottom > SCROLL_ENGAGEMENT_THRESHOLD) { - // Keep them at the same scroll position (reading older content) - container.scrollTop = savedScrollTopRef.current; + // Find the scroll container (StickToBottom creates one) + // It's the first parent with overflow-y scroll/auto + const findScrollContainer = (): HTMLElement | null => { + const candidates = document.querySelectorAll('[role="log"]'); + for (const candidate of candidates) { + // The scroll container is a direct child of the role="log" element + const children = candidate.children; + for (const child of children) { + const style = window.getComputedStyle(child); + if (style.overflowY === 'auto' || style.overflowY === 'scroll') { + return child as HTMLElement; } - - // Update saved values - lastContentHeightRef.current = currentHeight; } } - }); + return null; + }; - observer.observe(container, { - childList: true, - subtree: true, - characterData: true, - }); + const container = findScrollContainer(); + if (container) { + preservationContext.registerScrollContainer(container); + containerFoundRef.current = true; + } + }, [preservationContext]); - return () => observer.disconnect(); - }, []); + // Track engagement based on scroll position + useEffect(() => { + if (!preservationContext) return; + + if (!isAtBottom) { + // User is not at bottom - mark as engaged + preservationContext.markUserEngaged(); + } else { + // User is back at bottom - reset + preservationContext.resetEngagement(); + } + }, [isAtBottom, preservationContext]); return null; }; diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 4c97d581..c3ab6396 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -12,6 +12,7 @@ import { Conversation, ConversationContent, ConversationEmptyState, + ScrollPositionPreserver, } from '@/components/ai-elements/conversation' import { Message, @@ -481,6 +482,7 @@ export function ChatSidebar({ {/* Conversation area */}
+ {!hasConversation ? (