no auto scrolling when streaming

This commit is contained in:
Eva Ho 2025-11-12 18:19:39 -05:00
parent 78830f6e9d
commit dd63aac6bd
3 changed files with 101 additions and 91 deletions

View File

@ -100,41 +100,7 @@ export default function Chat({ chatId }: { chatId: string }) {
const sendMessageMutation = useSendMessage(chatId);
const latestMessageRef = useRef<HTMLDivElement>(null);
const prevMessageCountRef = useRef(messages.length);
// Scroll to latest message when messages change
useEffect(() => {
// Only scroll if a new message was actually added (not just re-render)
if (messages.length > prevMessageCountRef.current) {
if (latestMessageRef.current) {
// Find the scrollable parent container
let scrollContainer = latestMessageRef.current.parentElement;
while (scrollContainer) {
const overflowY = window.getComputedStyle(scrollContainer).overflowY;
if (overflowY === "auto" || overflowY === "scroll") {
break;
}
scrollContainer = scrollContainer.parentElement;
}
if (scrollContainer) {
const containerRect = scrollContainer.getBoundingClientRect();
const targetRect = latestMessageRef.current.getBoundingClientRect();
const scrollAmount =
targetRect.top - containerRect.top + scrollContainer.scrollTop;
scrollContainer.scrollTo({
top: scrollAmount,
behavior: "smooth",
});
}
}
}
prevMessageCountRef.current = messages.length;
}, [messages.length, latestMessageRef]);
// Simplified submit handler - ChatForm handles all the attachment logic
const handleChatFormSubmit = (
message: string,
options: {
@ -229,8 +195,9 @@ export default function Chat({ chatId }: { chatId: string }) {
<Conversation
key={chatId} // This key forces React to recreate the element when chatId changes
className={`flex-1 overscroll-contain select-none`}
isStreaming={isStreaming}
>
<ConversationContent>
<ConversationContent isStreaming={isStreaming}>
<MessageList
messages={messages}
isWaitingForLoad={isWaitingForLoad}

View File

@ -2,70 +2,112 @@
import clsx from "clsx";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import {
useCallback,
useEffect,
useRef,
createContext,
useContext,
} from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
// Create a context to share the "allow scroll" state
const ConversationControlContext = createContext<{
allowScroll: () => void;
} | null>(null);
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={clsx("relative h-full w-full overflow-y-auto", className)}
initial="instant"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationProps = ComponentProps<typeof StickToBottom> & {
isStreaming?: boolean;
};
export const Conversation = ({
className,
isStreaming = false,
children,
...props
}: ConversationProps) => {
const shouldStopScrollRef = useRef(true);
const allowScroll = useCallback(() => {
shouldStopScrollRef.current = false;
setTimeout(() => {
shouldStopScrollRef.current = true;
}, 100);
}, []);
return (
<ConversationControlContext.Provider value={{ allowScroll }}>
<StickToBottom
className={clsx("relative h-full w-full overflow-y-auto", className)}
initial="instant"
resize="instant"
role="log"
{...props}
>
<ConversationContentInternal
isStreaming={isStreaming}
shouldStopScrollRef={shouldStopScrollRef}
>
<>{children}</>
</ConversationContentInternal>
</StickToBottom>
</ConversationControlContext.Provider>
);
};
const ConversationContentInternal = ({
isStreaming,
shouldStopScrollRef,
children,
}: {
isStreaming: boolean;
shouldStopScrollRef: React.MutableRefObject<boolean>;
children: React.ReactNode;
}) => {
const { stopScroll } = useStickToBottomContext();
const stopScrollRef = useRef(stopScroll);
useEffect(() => {
stopScrollRef.current = stopScroll;
}, [stopScroll]);
useEffect(() => {
if (!isStreaming) return;
const interval = setInterval(() => {
if (shouldStopScrollRef.current) {
stopScrollRef.current();
}
}, 16);
if (shouldStopScrollRef.current) {
stopScrollRef.current();
}
return () => clearInterval(interval);
}, [isStreaming, shouldStopScrollRef]);
return <>{children}</>;
};
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
> & {
isStreaming?: boolean;
};
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={clsx("flex flex-col", className)}
{...props}
/>
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
}: ConversationContentProps) => {
return (
<StickToBottom.Content
className={clsx("flex flex-col", className)}
{...props}
/>
);
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={clsx(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className,
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<"button">;
export const ConversationScrollButton = ({
@ -73,10 +115,13 @@ export const ConversationScrollButton = ({
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const context = useContext(ConversationControlContext);
const handleScrollToBottom = useCallback(() => {
console.log("scrollToBottom");
context?.allowScroll();
scrollToBottom();
}, [scrollToBottom]);
}, [scrollToBottom, context]);
return (
!isAtBottom && (
@ -84,7 +129,7 @@ export const ConversationScrollButton = ({
className={clsx(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full z-50",
"bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700",
"p-3 shadow-lg hover:shadow-xl transition-all",
"p-1 shadow-lg hover:shadow-xl transition-all",
"text-neutral-700 dark:text-neutral-200 hover:scale-105",
"hover:cursor-pointer",
className,

View File

@ -1,10 +1,8 @@
export {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
type ConversationProps,
type ConversationContentProps,
type ConversationEmptyStateProps,
type ConversationScrollButtonProps,
} from "./conversation";