"use client"; import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react"; import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import { SessionPanelContent } from "@kandev/ui/pannel-session"; import type { RenderItem } from "@/hooks/use-processed-messages"; 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"; import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log"; const FIRST_INDEX_BASE = 100_000; const debugVirtuoso = createDebugLogger("chat:virtuoso"); const debugScrollParent = createDebugLogger("chat:virtuoso:scrollParent"); const debugFirstIndex = createDebugLogger("chat:virtuoso:firstIndex"); type VirtuosoBodyProps = MessageListProps & { scrollParent: HTMLDivElement; isRunning: boolean; lastTurnGroupId: string | null; hasMore: boolean; isLoadingMore: boolean; loadMore: () => Promise; Header: () => React.ReactNode; Footer: () => React.ReactNode; }; function computeFirstItemIndex(prevKeys: string[], prevIndex: number, keys: string[]): number { if (prevKeys.length > 0 && keys.length > prevKeys.length) { const oldFirstKey = prevKeys[0]; const newPos = keys.indexOf(oldFirstKey); if (newPos > 0) return prevIndex - newPos; if (newPos === -1) { for (let i = 0; i < prevKeys.length; i++) { const idx = keys.indexOf(prevKeys[i]); if (idx >= 0) return prevIndex - (idx - i); } } return prevIndex; } if (prevKeys.length === 0 && keys.length > 0) { return FIRST_INDEX_BASE - keys.length + 1; } return prevIndex; } type IndexState = { keys: string[]; firstItemIndex: number }; function useStableFirstItemIndex(items: RenderItem[]) { const keys = useMemo(() => items.map(getItemKey), [items]); const [state, setState] = useState(() => { const firstItemIndex = FIRST_INDEX_BASE - keys.length + 1; if (IS_DEBUG) { debugFirstIndex("init", { keyCount: keys.length, firstItemIndex, firstKey: keys[0] ?? "-", lastKey: keys[keys.length - 1] ?? "-", }); } return { keys, firstItemIndex }; }); if (keys !== state.keys) { const nextIndex = computeFirstItemIndex(state.keys, state.firstItemIndex, keys); if (IS_DEBUG) { debugFirstIndex("transition", { prevKeyCount: state.keys.length, nextKeyCount: keys.length, prevIndex: state.firstItemIndex, nextIndex, delta: nextIndex - state.firstItemIndex, prevFirstKey: state.keys[0] ?? "-", nextFirstKey: keys[0] ?? "-", prevLastKey: state.keys[state.keys.length - 1] ?? "-", nextLastKey: keys[keys.length - 1] ?? "-", }); } setState({ keys, firstItemIndex: nextIndex }); return nextIndex; } return state.firstItemIndex; } function useVirtuosoCallbacks(props: VirtuosoBodyProps) { const { items, sessionId, permissionsByToolCallId, childrenByParentToolCallId, taskId } = props; const { worktreePath, onOpenFile, lastTurnGroupId, isRunning, messages, sessionState, taskState, } = props; const { hasMore, isLoadingMore, loadMore } = props; const virtuosoRef = useRef(null); const itemCount = items.length; const firstItemIndex = useStableFirstItemIndex(items); const loadCooldownRef = useRef(false); const handleStartReached = useCallback(() => { if (hasMore && !isLoadingMore && !loadCooldownRef.current) { loadCooldownRef.current = true; loadMore().finally(() => { setTimeout(() => { loadCooldownRef.current = false; }, 500); }); } }, [hasMore, isLoadingMore, loadMore]); const handleScrollToMessage = useCallback( (messageId: string) => { const idx = items.findIndex((item) => { if (item.type === "turn_group") return item.messages.some((m) => m.id === messageId); if (item.type === "message") return item.message.id === messageId; return false; }); if (idx >= 0) virtuosoRef.current?.scrollToIndex({ index: firstItemIndex + idx, align: "center" }); }, [items, firstItemIndex], ); const computeItemKey = useCallback( (index: number) => { const item = items[index - firstItemIndex]; if (!item) return index; return getItemKey(item); }, [items, firstItemIndex], ); const renderItem = useCallback( (index: number) => { const item = items[index - firstItemIndex]; if (!item) return
; return (
); }, [ items, firstItemIndex, sessionId, permissionsByToolCallId, childrenByParentToolCallId, taskId, worktreePath, onOpenFile, lastTurnGroupId, isRunning, messages, sessionState, taskState, handleScrollToMessage, ], ); return { virtuosoRef, itemCount, firstItemIndex, handleStartReached, computeItemKey, renderItem }; } const FOLLOW_SMOOTH = "smooth" as const; const followOutput = (isAtBottom: boolean) => (isAtBottom ? FOLLOW_SMOOTH : false); function VirtuosoBody(props: VirtuosoBodyProps) { const { scrollParent, Header, Footer } = props; const { virtuosoRef, itemCount, firstItemIndex, handleStartReached, computeItemKey, renderItem } = useVirtuosoCallbacks(props); // Captured once on mount — `initialTopMostItemIndex` only takes effect on // Virtuoso's first render, so logging it here tells us which item Virtuoso // anchored on for that lifecycle. const mountSnapshotRef = useRef<{ itemCount: number; firstItemIndex: number } | null>(null); useEffect(() => { if (!IS_DEBUG) return; if (mountSnapshotRef.current) return; mountSnapshotRef.current = { itemCount, firstItemIndex }; debugVirtuoso("mount", { itemCount, firstItemIndex, initialTopMostItemIndex: itemCount - 1, hasMore: props.hasMore, isRunning: props.isRunning, lastTurnGroupId: props.lastTurnGroupId ?? "-", }); }, [itemCount, firstItemIndex, props.hasMore, props.isRunning, props.lastTurnGroupId]); return ( ); } type VirtuosoSnapshot = { branch: string; itemCount: number; messageCount: number; scrollParentReady: boolean; }; function virtuosoSnapshotChanged(prev: VirtuosoSnapshot | null, next: VirtuosoSnapshot): boolean { if (!prev) return true; return ( prev.branch !== next.branch || prev.itemCount !== next.itemCount || prev.messageCount !== next.messageCount || prev.scrollParentReady !== next.scrollParentReady ); } type VirtuosoDebugExtras = { sessionId: string | null | undefined; messagesLoading: boolean; isInitialLoading: boolean; showLoadingState: boolean; sessionState: string | null | undefined; lastItemKey: string; }; function logVirtuosoSnapshotChange( prev: VirtuosoSnapshot | null, next: VirtuosoSnapshot, extras: VirtuosoDebugExtras, ) { debugVirtuoso(prev ? "snapshot-change" : "snapshot-init", { sessionId: extras.sessionId ?? "-", ...next, prevBranch: prev?.branch ?? "-", prevItemCount: prev?.itemCount ?? -1, prevMessageCount: prev?.messageCount ?? -1, prevScrollParentReady: prev?.scrollParentReady ?? false, messagesLoading: extras.messagesLoading, isInitialLoading: extras.isInitialLoading, showLoadingState: extras.showLoadingState, sessionState: extras.sessionState ?? "-", lastItemKey: extras.lastItemKey, initialTopMostItemIndex: next.itemCount - 1, }); } type UseVirtuosoDebugSnapshotArgs = { items: RenderItem[]; messages: { length: number }; scrollParent: HTMLDivElement | null; sessionId: string | null | undefined; messagesLoading: boolean; isInitialLoading: boolean; showLoadingState: boolean; sessionState: string | null | undefined; }; /** Track which render branch fires and how itemCount/messageCount transition. */ function useVirtuosoDebugSnapshot({ items, messages, scrollParent, sessionId, messagesLoading, isInitialLoading, showLoadingState, sessionState, }: UseVirtuosoDebugSnapshotArgs) { const prevSnapshotRef = useRef(null); useEffect(() => { if (!IS_DEBUG) return; const snapshot: VirtuosoSnapshot = { branch: isInitialLoading || items.length === 0 ? "fallback" : "virtuoso", itemCount: items.length, messageCount: messages.length, scrollParentReady: Boolean(scrollParent), }; const prev = prevSnapshotRef.current; if (!virtuosoSnapshotChanged(prev, snapshot)) return; const lastItem = items[items.length - 1]; logVirtuosoSnapshotChange(prev, snapshot, { sessionId, messagesLoading, isInitialLoading, showLoadingState, sessionState, lastItemKey: lastItem ? getItemKey(lastItem) : "-", }); prevSnapshotRef.current = snapshot; }, [ items, messages.length, scrollParent, sessionId, messagesLoading, isInitialLoading, showLoadingState, sessionState, ]); } /** Defer providing scroll parent to Virtuoso until the element has non-zero size. */ function useVisibleScrollParent() { const [scrollParent, setScrollParent] = useState(null); const nodeRef = useRef(null); const setScrollRef = useCallback((node: HTMLDivElement | null) => { nodeRef.current = node; if (node && node.offsetHeight > 0) { if (IS_DEBUG) { debugScrollParent("ref-callback-ready", { offsetHeight: node.offsetHeight, path: "synchronous", }); } setScrollParent(node); } else if (IS_DEBUG) { debugScrollParent("ref-callback-defer", { hasNode: Boolean(node), offsetHeight: node?.offsetHeight ?? null, reason: !node ? "no-node" : "zero-height", }); } }, []); useEffect(() => { const node = nodeRef.current; if (!node || scrollParent) return; if (IS_DEBUG) { debugScrollParent("ro-attach", { initialHeight: node.offsetHeight, }); } const ro = new ResizeObserver((entries) => { for (const entry of entries) { if (entry.contentRect.height > 0) { if (IS_DEBUG) { debugScrollParent("ro-ready", { height: entry.contentRect.height, }); } setScrollParent(node); ro.disconnect(); return; } } }); ro.observe(node); return () => ro.disconnect(); }, [scrollParent]); return { scrollParent, setScrollRef }; } export const VirtuosoMessageList = memo(function VirtuosoMessageList(props: MessageListProps) { const { items, messages, footerActionMessages, sessionId, messagesLoading, isWorking, sessionState, } = props; const { scrollParent, setScrollRef } = useVisibleScrollParent(); const isInitialLoading = messagesLoading && messages.length === 0; const isNonLoadableSession = !sessionState || ["CREATED", "FAILED", "COMPLETED", "CANCELLED"].includes(sessionState); const showLoadingState = (messagesLoading || isInitialLoading) && !isWorking && !isNonLoadableSession; const { loadMore, hasMore, isLoading: isLoadingMore } = useLazyLoadMessages(sessionId); const isRunning = getSessionRunningState(sessionState); const lastTurnGroupId = useMemo(() => getLastTurnGroupId(items), [items]); // Track which render branch fires and how itemCount/messageCount transition. // See useVirtuosoDebugSnapshot for details on the remote-executor scroll bug. useVirtuosoDebugSnapshot({ items, messages, scrollParent, sessionId, messagesLoading, isInitialLoading, showLoadingState, sessionState, }); const Header = useCallback( () => ( ), [isLoadingMore, hasMore, showLoadingState, messagesLoading, isInitialLoading, messages.length], ); const footerActions = useMemo(() => footerActionMessages ?? [], [footerActionMessages]); const Footer = useCallback( () => ( <> {footerActions.map((msg) => ( ))} ), [sessionId, sessionState, messages, footerActions], ); if (isInitialLoading || items.length === 0) { return ( {footerActions.map((msg) => ( ))} ); } return ( {scrollParent && ( )} ); });