@@ -30,10 +30,17 @@ interface IMessagesList { } const STREAM_STICK_BOTTOM_THRESHOLD_PX = 200 +const GENERATION_SPACER_TOP_OFFSET_PX = 16 export const ChatMessagesList: React.FC = memo( ({ messageResponse, onLoadImage, setResendValue, setQuoteValue, modelTitle, device, modelType, botParams, getMessagesPagination, deleteMessage, loading, paginationLoading = false }) => { const paginationScroll = React.useRef(null) + const messagesContentRef = React.useRef(null) + const generationSpacerRef = React.useRef(null) + const generationSpacerHeightRef = useRef(0) + const generationObserverRef = useRef(null) + const messageResponseRef = useRef(messageResponse) + messageResponseRef.current = messageResponse const [scrollBottom, setScrollBottom] = React.useState(0) const prevMessagesSnapshotRef = useRef<{ length: number @@ -55,10 +62,63 @@ export const ChatMessagesList: React.FC = memo( const isRestoringScrollRef = useRef(false) const scrollBottomRef = useRef(0) const lastSeenLastUidRef = useRef(null) + const isGenerating = loading && !paginationLoading const getMessageTopInViewport = (block: HTMLDivElement, el: HTMLElement) => el.getBoundingClientRect().top - block.getBoundingClientRect().top + const setGenerationSpacerHeight = (height: number) => { + const next = Math.max(0, Math.round(height)) + generationSpacerHeightRef.current = next + if (generationSpacerRef.current) { + generationSpacerRef.current.style.height = `${next}px` + } + } + + const computeGenerationSpacerHeight = (block: HTMLDivElement) => { + const messages = messageResponseRef.current + if (!messages?.length) { + return 0 + } + + let lastUserUid: string | undefined + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (!messages[i].from_model) { + lastUserUid = messages[i].uid + break + } + } + + if (!lastUserUid) { + return 0 + } + + const lastUserEl = block.querySelector( + `[data-message-uid="${CSS.escape(lastUserUid)}"]` + ) as HTMLElement | null + const lastUid = messages[messages.length - 1]?.uid + const lastMsgEl = lastUid + ? (block.querySelector(`[data-message-uid="${CSS.escape(lastUid)}"]`) as HTMLElement | null) + : null + + if (!lastUserEl || !lastMsgEl) { + return Math.max(0, block.clientHeight - GENERATION_SPACER_TOP_OFFSET_PX) + } + + const containerRect = block.getBoundingClientRect() + const userMsgScrollTop = + lastUserEl.getBoundingClientRect().top - containerRect.top + block.scrollTop + const lastMsgScrollBottom = + lastMsgEl.getBoundingClientRect().bottom - containerRect.top + block.scrollTop + const turnHeight = Math.max(0, lastMsgScrollBottom - userMsgScrollTop) + + return Math.max(0, block.clientHeight - turnHeight - GENERATION_SPACER_TOP_OFFSET_PX) + } + + const applyGenerationSpacer = (block: HTMLDivElement) => { + setGenerationSpacerHeight(computeGenerationSpacerHeight(block)) + } + const captureScrollAnchor = (block: HTMLDivElement) => { const anchorUid = messageResponse?.[0]?.uid if (!anchorUid) { @@ -178,6 +238,7 @@ export const ChatMessagesList: React.FC = memo( return () => { prependRestoreObserverRef.current?.disconnect() initialScrollObserverRef.current?.disconnect() + generationObserverRef.current?.disconnect() if (prependRestoreTimeoutRef.current) { clearTimeout(prependRestoreTimeoutRef.current) } @@ -192,6 +253,7 @@ export const ChatMessagesList: React.FC = memo( prevMessagesSnapshotRef.current = null lastSeenLastUidRef.current = null scrollAnchorRef.current = null + setGenerationSpacerHeight(0) initialScrollObserverRef.current?.disconnect() if (initialScrollTimeoutRef.current) { clearTimeout(initialScrollTimeoutRef.current) @@ -199,6 +261,39 @@ export const ChatMessagesList: React.FC = memo( } }, [messageResponse]) + useLayoutEffect(() => { + const block = paginationScroll.current + if (!block) { + return + } + + if (!isGenerating) { + generationObserverRef.current?.disconnect() + generationObserverRef.current = null + setGenerationSpacerHeight(0) + return + } + + const syncSpacer = () => { + applyGenerationSpacer(block) + } + + syncSpacer() + + generationObserverRef.current?.disconnect() + const content = messagesContentRef.current + if (content) { + const observer = new ResizeObserver(syncSpacer) + generationObserverRef.current = observer + observer.observe(content) + } + + return () => { + generationObserverRef.current?.disconnect() + generationObserverRef.current = null + } + }, [isGenerating]) + useLayoutEffect(() => { if (!messageResponse?.length) { return @@ -220,7 +315,15 @@ export const ChatMessagesList: React.FC = memo( const prev = prevMessagesSnapshotRef.current if (!prev) { - scrollToBottom(block, true) + if (isGenerating) { + applyGenerationSpacer(block) + block.scrollTo({ + top: block.scrollHeight, + behavior: 'smooth', + }) + } else { + scrollToBottom(block, true) + } prevMessagesSnapshotRef.current = snapshot return } @@ -245,16 +348,23 @@ export const ChatMessagesList: React.FC = memo( if (prepended && scrollAnchorRef.current) { restorePrependScroll(block) } else if (appended) { + if (isGenerating) { + applyGenerationSpacer(block) + } block.scrollTo({ top: block.scrollHeight, behavior: 'smooth', }) } else if (streamingGrowth && scrollBottomRef.current < STREAM_STICK_BOTTOM_THRESHOLD_PX) { - block.scrollTop = block.scrollHeight + applyGenerationSpacer(block) + // While the spacer absorbs growth, scrollHeight stays stable; only stick when it collapses. + if (generationSpacerHeightRef.current === 0) { + block.scrollTop = block.scrollHeight + } } prevMessagesSnapshotRef.current = snapshot - }, [messageResponse]) + }, [messageResponse, isGenerating]) useEffect(() => { const lastUid = messageResponse?.[messageResponse.length - 1]?.uid @@ -334,6 +444,10 @@ export const ChatMessagesList: React.FC = memo( return } + if (isGenerating) { + applyGenerationSpacer(block) + } + block.scrollTo({ top: block.scrollHeight, behavior: 'smooth', @@ -366,38 +480,51 @@ export const ChatMessagesList: React.FC = memo( )} - {messageResponse?.length === 0 && status === 'authenticated' ? ( - <>{desktop && modelType !== 'deepl' && } - ) : ( - messageResponse?.map((message, idx) => { - const isStreaming = - loading && - !paginationLoading && - message.from_model && - message.uid.startsWith('streaming:') && - idx === messageResponse.length - 1 - - return ( - - ) - }) - )} + + {messageResponse?.length === 0 && status === 'authenticated' ? ( + <>{desktop && modelType !== 'deepl' && } + ) : ( + messageResponse?.map((message, idx) => { + const isStreaming = + loading && + !paginationLoading && + message.from_model && + message.uid.startsWith('streaming:') && + idx === messageResponse.length - 1 + + return ( + + ) + }) + )} + + + )