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 ? (