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 35baf6c4..bba0b9e8 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx @@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { ArrowDownIcon } from "lucide-react"; import type { ComponentProps } from "react"; -import { useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; export type ConversationProps = ComponentProps; @@ -13,11 +13,109 @@ export const Conversation = ({ className, ...props }: ConversationProps) => ( ); +// Threshold in pixels - if user scrolls more than this from bottom, they're considered "engaged" +const SCROLL_ENGAGEMENT_THRESHOLD = 100; + +/** + * Component that preserves scroll position when user has scrolled away from bottom. + * Place this inside a StickToBottom context to prevent unwanted scroll jumps. + */ +export const ScrollPositionPreserver = () => { + const { isAtBottom } = useStickToBottomContext(); + const scrollContainerRef = useRef(null); + const isUserEngagedRef = useRef(false); + const savedScrollTopRef = useRef(null); + const lastContentHeightRef = useRef(0); + + 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; + }; + + // 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; + } + + // Update saved values + lastContentHeightRef.current = currentHeight; + } + } + }); + + observer.observe(container, { + childList: true, + subtree: true, + characterData: true, + }); + + return () => observer.disconnect(); + }, []); + + return null; +}; + export type ConversationContentProps = ComponentProps< typeof StickToBottom.Content >; diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index af0d1896..cee59809 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -11,7 +11,6 @@ import { FilePlus, Folder, FolderPlus, - MessageSquare, Mic, Network, Pencil, @@ -689,9 +688,7 @@ function TasksSection({ actions?.onSelectRun(run.id)} - className="gap-2" > - {run.title || '(Untitled chat)'}