"use client"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, memo } from "react"; import { SessionPanelContent } from "@kandev/ui/pannel-session"; import { useDockviewStore } from "@/lib/state/dockview-store"; import type { Message } from "@/lib/types/http"; import { AgentStatus } from "@/components/task/chat/messages/agent-status"; import { MessageRenderer } from "@/components/task/chat/message-renderer"; import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages"; import { type MessageListProps, MessageListStatus, MessageItem, getItemKey, getSessionRunningState, getLastTurnGroupId, } from "./message-list-shared"; /** * Continuously captures scroll state via scroll listener. * On prepend (itemCount increases), restores scroll position so the user * stays at the same visual spot. */ function useScrollPositionOnPrepend( scrollRef: React.RefObject, itemCount: number, ) { const scrollState = useRef({ scrollHeight: 0, scrollTop: 0 }); const prevItemCount = useRef(itemCount); useEffect(() => { const el = scrollRef.current; if (!el) return; const onScroll = () => { scrollState.current.scrollHeight = el.scrollHeight; scrollState.current.scrollTop = el.scrollTop; }; onScroll(); el.addEventListener("scroll", onScroll, { passive: true }); return () => el.removeEventListener("scroll", onScroll); }, [scrollRef]); useLayoutEffect(() => { const el = scrollRef.current; if (!el || itemCount <= prevItemCount.current) { prevItemCount.current = itemCount; return; } const prev = scrollState.current; const delta = el.scrollHeight - prev.scrollHeight; if (delta > 0) { el.scrollTop = prev.scrollTop + delta; } prevItemCount.current = itemCount; }, [itemCount, scrollRef]); } /** * Observes a sentinel element at the top of the list to trigger lazy loading. * Uses a callback ref so the observer reconnects when the sentinel remounts. * * Handles the timing issue where the sentinel DOM node mounts (callback ref fires) * before the useEffect creates the IntersectionObserver. The sentinelNodeRef bridges * the gap: the callback ref stores the node, and the effect observes it if present. */ function useLazyLoadSentinel( scrollRef: React.RefObject, hasMore: boolean, isLoadingMore: boolean, loadMore: () => Promise, ) { const stateRef = useRef({ hasMore, isLoadingMore }); useEffect(() => { stateRef.current = { hasMore, isLoadingMore }; }, [hasMore, isLoadingMore]); const observerRef = useRef(null); const sentinelNodeRef = useRef(null); // Create/destroy observer when scroll container changes useEffect(() => { const root = scrollRef.current; if (!root) return; const observer = new IntersectionObserver( (entries) => { const { hasMore, isLoadingMore } = stateRef.current; const isIntersecting = entries[0]?.isIntersecting; if (isIntersecting && hasMore && !isLoadingMore) { loadMore(); } }, { root, rootMargin: "200px 0px 0px 0px" }, ); observerRef.current = observer; // If sentinel already mounted before this effect ran, observe it now if (sentinelNodeRef.current) { observer.observe(sentinelNodeRef.current); } return () => { observer.disconnect(); observerRef.current = null; }; }, [scrollRef, loadMore]); // Callback ref — stores node and observes if observer already exists const sentinelRef = useCallback((node: HTMLDivElement | null) => { sentinelNodeRef.current = node; const observer = observerRef.current; if (observer) { observer.disconnect(); if (node) { observer.observe(node); } } }, []); return sentinelRef; } /** * Auto-scrolls to bottom when new messages arrive (if user is near bottom) * or when the agent starts working (isWorking transitions to true). */ function useAutoScroll( scrollRef: React.RefObject, messages: Message[], isWorking: boolean, ) { const isNearBottomRef = useRef(true); const prevIsWorkingRef = useRef(isWorking); useEffect(() => { const el = scrollRef.current; if (!el) return; const onScroll = () => { isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 100; }; el.addEventListener("scroll", onScroll, { passive: true }); return () => el.removeEventListener("scroll", onScroll); }, [scrollRef]); // When isWorking transitions to true, force scroll to bottom useEffect(() => { if (isWorking && !prevIsWorkingRef.current) { const el = scrollRef.current; if (el) { el.scrollTop = el.scrollHeight; isNearBottomRef.current = true; } } prevIsWorkingRef.current = isWorking; }, [isWorking, scrollRef]); // Auto-scroll on new messages if near bottom useLayoutEffect(() => { const el = scrollRef.current; if (!el) return; // Skip auto-scroll when a layout rebuild scroll restore is pending if (useDockviewStore.getState().pendingChatScrollTop !== null) return; if (isNearBottomRef.current) { el.scrollTop = el.scrollHeight; } }, [messages, scrollRef]); } function useScrollToMessage() { return useCallback((messageId: string) => { const el = document.getElementById(`msg-${messageId}`); el?.scrollIntoView({ block: "center", behavior: "smooth" }); }, []); } export const NativeMessageList = memo(function NativeMessageList({ items, messages, footerActionMessages, permissionsByToolCallId, childrenByParentToolCallId, taskId, sessionId, messagesLoading, isWorking, sessionState, taskState, worktreePath, onOpenFile, }: MessageListProps) { const scrollRef = useRef(null); const isInitialLoading = messagesLoading && messages.length === 0; const isNonLoadableSession = !sessionState || ["CREATED", "FAILED", "COMPLETED", "CANCELLED"].includes(sessionState); const showLoadingState = isInitialLoading && !isWorking && !isNonLoadableSession; const { loadMore, hasMore, isLoading: isLoadingMore } = useLazyLoadMessages(sessionId); const isRunning = getSessionRunningState(sessionState); const lastTurnGroupId = useMemo(() => getLastTurnGroupId(items), [items]); const handleScrollToMessage = useScrollToMessage(); useScrollPositionOnPrepend(scrollRef, items.length); const sentinelRef = useLazyLoadSentinel(scrollRef, hasMore, isLoadingMore, loadMore); useAutoScroll(scrollRef, messages, isWorking); // Scroll to bottom on initial load const didInitialScroll = useRef(false); useEffect(() => { if (didInitialScroll.current || items.length === 0) return; const el = scrollRef.current; if (!el) return; // If a layout rebuild scroll restore is pending, skip initial scroll // (the restore handler will set the correct position) if (useDockviewStore.getState().pendingChatScrollTop !== null) { didInitialScroll.current = true; return; } el.scrollTop = el.scrollHeight; didInitialScroll.current = true; }, [items.length]); return ( {/* Sentinel for lazy loading older messages */} {hasMore &&
} {items.map((item) => { const key = getItemKey(item); return (
); })} {(footerActionMessages ?? []).map((msg: Message) => ( ))} {/* Bottom anchor — browser keeps scroll pinned here when new content appends */}
); });