feat(chat): add message history pagination with scroll-up loading
Return latest messages by default instead of oldest. Support paginated loading of older messages when scrolling up via IntersectionObserver, with scrollHeight compensation to preserve scroll position. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c2b5ada2ef
commit
65c2fea1b6
12 changed files with 232 additions and 28 deletions
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useRef, useEffect, useCallback } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { ChatInput } from "@multica/ui/components/chat-input";
|
||||
|
|
@ -30,9 +30,12 @@ export interface ChatViewProps {
|
|||
streamingIds: Set<string>;
|
||||
isLoading: boolean;
|
||||
isLoadingHistory: boolean;
|
||||
isLoadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
error: ChatViewError | null;
|
||||
pendingApprovals: ChatViewApproval[];
|
||||
sendMessage: (text: string) => void;
|
||||
loadMore?: () => void;
|
||||
resolveApproval: (approvalId: string, decision: "allow-once" | "allow-always" | "deny") => void;
|
||||
onDisconnect?: () => void;
|
||||
}
|
||||
|
|
@ -42,15 +45,76 @@ export function ChatView({
|
|||
streamingIds,
|
||||
isLoading,
|
||||
isLoadingHistory,
|
||||
isLoadingMore = false,
|
||||
hasMore = false,
|
||||
error,
|
||||
pendingApprovals,
|
||||
sendMessage,
|
||||
loadMore,
|
||||
resolveApproval,
|
||||
onDisconnect,
|
||||
}: ChatViewProps) {
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const fadeStyle = useScrollFade(mainRef);
|
||||
useAutoScroll(mainRef);
|
||||
const { suppressAutoScroll } = useAutoScroll(mainRef);
|
||||
|
||||
// scrollHeight compensation for prepended messages
|
||||
const prevScrollHeightRef = useRef(0);
|
||||
const isPrependingRef = useRef(false);
|
||||
const unlockRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Snapshot scrollHeight before prepend render
|
||||
const onLoadMore = useCallback(() => {
|
||||
if (!loadMore || !mainRef.current) return;
|
||||
const el = mainRef.current;
|
||||
prevScrollHeightRef.current = el.scrollHeight;
|
||||
isPrependingRef.current = true;
|
||||
// Lock auto-scroll during prepend
|
||||
unlockRef.current = suppressAutoScroll();
|
||||
loadMore();
|
||||
}, [loadMore, suppressAutoScroll]);
|
||||
|
||||
// After messages change, compensate scroll position if we just prepended
|
||||
useEffect(() => {
|
||||
const el = mainRef.current;
|
||||
if (!el || !isPrependingRef.current) return;
|
||||
|
||||
isPrependingRef.current = false;
|
||||
|
||||
// Double-rAF ensures DOM layout is complete before compensating
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const newScrollHeight = el.scrollHeight;
|
||||
const heightDiff = newScrollHeight - prevScrollHeightRef.current;
|
||||
if (heightDiff > 0) {
|
||||
el.scrollTop = el.scrollTop + heightDiff;
|
||||
}
|
||||
// Release auto-scroll lock after position is restored
|
||||
unlockRef.current?.();
|
||||
unlockRef.current = null;
|
||||
});
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
// IntersectionObserver to trigger loadMore when sentinel is visible
|
||||
// Skip during initial history load to avoid premature triggering
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel || isLoadingHistory) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting && hasMore && !isLoadingMore) {
|
||||
onLoadMore();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "100px" },
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, isLoadingMore, isLoadingHistory, onLoadMore]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
|
|
@ -122,6 +186,15 @@ export function ChatView({
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Sentinel element for IntersectionObserver load-more trigger */}
|
||||
<div ref={sentinelRef} className="h-px shrink-0" />
|
||||
{isLoadingMore && (
|
||||
<div className="flex justify-center py-3">
|
||||
<div className="text-xs text-muted-foreground animate-pulse">
|
||||
Loading older messages...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MessageList messages={messages} streamingIds={streamingIds} />
|
||||
{pendingApprovals.length > 0 && (
|
||||
<div className="relative px-4 max-w-4xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { type RefObject, useEffect, useRef } from "react"
|
||||
import { type RefObject, useEffect, useRef, useCallback } from "react"
|
||||
|
||||
/**
|
||||
* Auto-scrolls a scroll container to the bottom when its inner content grows,
|
||||
* as long as the user hasn't scrolled up to read older content.
|
||||
*
|
||||
* Observes child element size changes via ResizeObserver on all children,
|
||||
* plus MutationObserver for added/removed nodes. Works for new messages,
|
||||
* history loads, streaming updates, and image loads.
|
||||
* Returns a `lockRef` that can be set to `true` to temporarily suppress
|
||||
* auto-scroll (e.g. during history prepend operations).
|
||||
*/
|
||||
export function useAutoScroll(ref: RefObject<HTMLElement | null>) {
|
||||
const stickRef = useRef(true)
|
||||
const lockRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
|
|
@ -25,6 +25,7 @@ export function useAutoScroll(ref: RefObject<HTMLElement | null>) {
|
|||
}
|
||||
|
||||
const onContentChange = () => {
|
||||
if (lockRef.current) return
|
||||
if (stickRef.current) {
|
||||
scrollToBottom()
|
||||
}
|
||||
|
|
@ -61,4 +62,12 @@ export function useAutoScroll(ref: RefObject<HTMLElement | null>) {
|
|||
mo.disconnect()
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
/** Temporarily suppress auto-scroll during prepend operations */
|
||||
const suppressAutoScroll = useCallback(() => {
|
||||
lockRef.current = true
|
||||
return () => { lockRef.current = false }
|
||||
}, [])
|
||||
|
||||
return { suppressAutoScroll }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue