mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-03 19:25:19 +02:00
feat: add ScrollPositionPreserver component to manage scroll engagement in conversations.
This commit is contained in:
parent
23a1544a17
commit
d903a8cae3
3 changed files with 131 additions and 93 deletions
|
|
@ -20,6 +20,7 @@ import {
|
||||||
Conversation,
|
Conversation,
|
||||||
ConversationContent,
|
ConversationContent,
|
||||||
ConversationEmptyState,
|
ConversationEmptyState,
|
||||||
|
ScrollPositionPreserver,
|
||||||
} from '@/components/ai-elements/conversation';
|
} from '@/components/ai-elements/conversation';
|
||||||
import {
|
import {
|
||||||
Message,
|
Message,
|
||||||
|
|
@ -1821,6 +1822,7 @@ function App() {
|
||||||
) : (
|
) : (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<Conversation className="relative flex-1 overflow-y-auto">
|
<Conversation className="relative flex-1 overflow-y-auto">
|
||||||
|
<ScrollPositionPreserver />
|
||||||
<ConversationContent className={conversationContentClassName}>
|
<ConversationContent className={conversationContentClassName}>
|
||||||
{!hasConversation ? (
|
{!hasConversation ? (
|
||||||
<ConversationEmptyState className="h-auto">
|
<ConversationEmptyState className="h-auto">
|
||||||
|
|
|
||||||
|
|
@ -3,115 +3,149 @@
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ArrowDownIcon } from "lucide-react";
|
import { ArrowDownIcon } from "lucide-react";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||||
|
|
||||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
// 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<ScrollPreservationContextValue | null>(null);
|
||||||
<StickToBottom
|
|
||||||
className={cn("relative flex-1 overflow-y-hidden", className)}
|
|
||||||
initial="smooth"
|
|
||||||
resize="smooth"
|
|
||||||
role="log"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Threshold in pixels - if user scrolls more than this from bottom, they're considered "engaged"
|
export type ConversationProps = ComponentProps<typeof StickToBottom> & {
|
||||||
const SCROLL_ENGAGEMENT_THRESHOLD = 100;
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Conversation = ({ className, children, ...props }: ConversationProps) => {
|
||||||
|
const [scrollContainer, setScrollContainer] = useState<HTMLElement | null>(null);
|
||||||
|
const isUserEngagedRef = useRef(false);
|
||||||
|
const savedScrollTopRef = useRef<number>(0);
|
||||||
|
const lastScrollHeightRef = useRef<number>(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 (
|
||||||
|
<ScrollPreservationContext.Provider value={contextValue}>
|
||||||
|
<StickToBottom
|
||||||
|
className={cn("relative flex-1 overflow-y-hidden", className)}
|
||||||
|
initial="smooth"
|
||||||
|
resize="smooth"
|
||||||
|
role="log"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</StickToBottom>
|
||||||
|
</ScrollPreservationContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component that preserves scroll position when user has scrolled away from bottom.
|
* Component that tracks scroll engagement and preserves position.
|
||||||
* Place this inside a StickToBottom context to prevent unwanted scroll jumps.
|
* Must be used inside Conversation component.
|
||||||
*/
|
*/
|
||||||
export const ScrollPositionPreserver = () => {
|
export const ScrollPositionPreserver = () => {
|
||||||
const { isAtBottom } = useStickToBottomContext();
|
const { isAtBottom } = useStickToBottomContext();
|
||||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
const preservationContext = useContext(ScrollPreservationContext);
|
||||||
const isUserEngagedRef = useRef(false);
|
const containerFoundRef = useRef(false);
|
||||||
const savedScrollTopRef = useRef<number | null>(null);
|
|
||||||
const lastContentHeightRef = useRef<number>(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Find and register scroll container on mount
|
||||||
// Find the scroll container (StickToBottom creates a scrollable element)
|
useLayoutEffect(() => {
|
||||||
const findScrollContainer = () => {
|
if (containerFoundRef.current || !preservationContext) return;
|
||||||
// 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
|
// Find the scroll container (StickToBottom creates one)
|
||||||
const container = findScrollContainer();
|
// It's the first parent with overflow-y scroll/auto
|
||||||
if (container) {
|
const findScrollContainer = (): HTMLElement | null => {
|
||||||
scrollContainerRef.current = container;
|
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;
|
||||||
// Track when user scrolls away from bottom
|
for (const child of children) {
|
||||||
useEffect(() => {
|
const style = window.getComputedStyle(child);
|
||||||
if (!isAtBottom) {
|
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
|
||||||
// User is not at bottom - they've scrolled up
|
return child as HTMLElement;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
observer.observe(container, {
|
const container = findScrollContainer();
|
||||||
childList: true,
|
if (container) {
|
||||||
subtree: true,
|
preservationContext.registerScrollContainer(container);
|
||||||
characterData: true,
|
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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
Conversation,
|
Conversation,
|
||||||
ConversationContent,
|
ConversationContent,
|
||||||
ConversationEmptyState,
|
ConversationEmptyState,
|
||||||
|
ScrollPositionPreserver,
|
||||||
} from '@/components/ai-elements/conversation'
|
} from '@/components/ai-elements/conversation'
|
||||||
import {
|
import {
|
||||||
Message,
|
Message,
|
||||||
|
|
@ -481,6 +482,7 @@ export function ChatSidebar({
|
||||||
{/* Conversation area */}
|
{/* Conversation area */}
|
||||||
<div className="flex min-h-0 flex-1 flex-col relative">
|
<div className="flex min-h-0 flex-1 flex-col relative">
|
||||||
<Conversation className="relative flex-1 overflow-y-auto">
|
<Conversation className="relative flex-1 overflow-y-auto">
|
||||||
|
<ScrollPositionPreserver />
|
||||||
<ConversationContent className={hasConversation ? "px-4 pb-24" : "px-4 min-h-full items-center justify-center"}>
|
<ConversationContent className={hasConversation ? "px-4 pb-24" : "px-4 min-h-full items-center justify-center"}>
|
||||||
{!hasConversation ? (
|
{!hasConversation ? (
|
||||||
<ConversationEmptyState className="h-auto">
|
<ConversationEmptyState className="h-auto">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue