From 4c6e9178f60c77298860987ef54c6dac3412b20e Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:37:50 +0300 Subject: [PATCH] fix(chat): stabilize message scroll controls --- .../chat/hooks/useChatSessionState.ts | 54 ++++++++++------- src/components/chat/view/ChatInterface.tsx | 21 +++++-- .../chat/view/subcomponents/ChatComposer.tsx | 20 +------ .../view/subcomponents/ChatMessagesPane.tsx | 58 +++++++++++-------- 4 files changed, 85 insertions(+), 68 deletions(-) diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index 780e5ec3..47f66830 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -121,6 +121,7 @@ export function useChatSessionState({ const [viewHiddenCount, setViewHiddenCount] = useState(0); const scrollContainerRef = useRef(null); + const wasNearTopRef = useRef(false); const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null); const searchScrollActiveRef = useRef(false); const isLoadingSessionRef = useRef(false); @@ -185,6 +186,7 @@ export function useChatSessionState({ setShowLoadAllOverlay(false); setViewHiddenCount(0); setSearchTarget(null); + wasNearTopRef.current = false; searchScrollActiveRef.current = false; topLoadLockRef.current = false; pendingScrollRestoreRef.current = null; @@ -357,8 +359,25 @@ export function useChatSessionState({ const nearBottom = isNearBottom(); setIsUserScrolledUp(!nearBottom); + const scrolledNearTop = container.scrollTop < 100; + + // "Load all" prompt: appear (with fade-in) when the user reaches the top + if (scrolledNearTop && hasMoreMessages && !allMessagesLoadedRef.current) { + if (!wasNearTopRef.current) { + wasNearTopRef.current = true; + if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); + + setShowLoadAllOverlay(true); + loadAllOverlayTimerRef.current = setTimeout(() => { + setShowLoadAllOverlay(false); + loadAllOverlayTimerRef.current = null; + }, 5000); + } + } else if (!scrolledNearTop) { + wasNearTopRef.current = false; + } + if (!allMessagesLoadedRef.current) { - const scrolledNearTop = container.scrollTop < 100; if (!scrolledNearTop) { topLoadLockRef.current = false; return; } if (topLoadLockRef.current) { if (container.scrollTop > 20) topLoadLockRef.current = false; @@ -367,7 +386,7 @@ export function useChatSessionState({ const didLoad = await loadOlderMessages(container); if (didLoad) topLoadLockRef.current = true; } - }, [isNearBottom, loadOlderMessages]); + }, [hasMoreMessages, isNearBottom, loadOlderMessages]); useLayoutEffect(() => { if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return; @@ -386,6 +405,7 @@ export function useChatSessionState({ } topLoadLockRef.current = false; pendingScrollRestoreRef.current = null; + wasNearTopRef.current = false; setIsUserScrolledUp(false); }, [selectedProject?.projectId, selectedSession?.id]); @@ -492,6 +512,7 @@ export function useChatSessionState({ setLoadAllJustFinished(false); setShowLoadAllOverlay(false); setViewHiddenCount(0); + wasNearTopRef.current = false; if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); @@ -720,23 +741,8 @@ export function useChatSessionState({ return () => container.removeEventListener('scroll', handleScroll); }, [handleScroll]); - // "Load all" overlay - const prevLoadingRef = useRef(false); - useEffect(() => { - const wasLoading = prevLoadingRef.current; - prevLoadingRef.current = isLoadingMoreMessages; - - if (wasLoading && !isLoadingMoreMessages && hasMoreMessages) { - if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); - setShowLoadAllOverlay(true); - loadAllOverlayTimerRef.current = setTimeout(() => setShowLoadAllOverlay(false), 2000); - } - if (!hasMoreMessages && !isLoadingMoreMessages) { - if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); - setShowLoadAllOverlay(false); - } - return () => { if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); }; - }, [isLoadingMoreMessages, hasMoreMessages]); + // "Load all" overlay visibility is driven by scroll-to-top in handleScroll; + // timers are cleared on session change via the reset effect above. const loadAllMessages = useCallback(async () => { if (!selectedSession || !selectedProject) return; @@ -746,6 +752,10 @@ export function useChatSessionState({ isLoadingMoreRef.current = true; setIsLoadingAllMessages(true); setShowLoadAllOverlay(true); + if (loadAllOverlayTimerRef.current) { + clearTimeout(loadAllOverlayTimerRef.current); + loadAllOverlayTimerRef.current = null; + } const container = scrollContainerRef.current; const previousScrollHeight = container ? container.scrollHeight : 0; @@ -772,7 +782,11 @@ export function useChatSessionState({ setLoadAllJustFinished(true); if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); - loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000); + loadAllFinishedTimerRef.current = setTimeout(() => { + setLoadAllJustFinished(false); + setShowLoadAllOverlay(false); + loadAllFinishedTimerRef.current = null; + }, 1000); } else { allMessagesLoadedRef.current = false; setShowLoadAllOverlay(false); diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index b466c6ba..87811c65 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { ArrowDownIcon } from 'lucide-react'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import { useWebSocket } from '../../../contexts/WebSocketContext'; @@ -362,7 +363,21 @@ function ChatInterface({ selectedProject={selectedProject} /> - + {isUserScrolledUp && chatMessages.length > 0 && ( +
+ +
+ )} + + 0} - onScrollToBottom={scrollToBottomAndReset} onSubmit={handleSubmit} isDragActive={isDragActive} attachedImages={attachedImages} @@ -430,6 +442,7 @@ function ChatInterface({ isTextareaExpanded={isTextareaExpanded} sendByCtrlEnter={sendByCtrlEnter} /> + diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index 6077ca2b..e7b36c60 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -11,7 +11,7 @@ import type { RefObject, TouchEvent, } from 'react'; -import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2 } from 'lucide-react'; +import { ImageIcon, MessageSquareIcon, XIcon, Loader2 } from 'lucide-react'; import { useVoiceInput } from '../../hooks/useVoiceInput'; import { useVoiceAvailable } from '../../hooks/useVoiceAvailable'; @@ -68,9 +68,6 @@ interface ChatComposerProps { onToggleCommandMenu: () => void; hasInput: boolean; onClearInput: () => void; - isUserScrolledUp: boolean; - hasMessages: boolean; - onScrollToBottom: () => void; onSubmit: (event: FormEvent | MouseEvent | TouchEvent) => void; isDragActive: boolean; attachedImages: File[]; @@ -122,9 +119,6 @@ export default function ChatComposer({ onToggleCommandMenu, hasInput, onClearInput, - isUserScrolledUp, - hasMessages, - onScrollToBottom, onSubmit, isDragActive, attachedImages, @@ -219,18 +213,6 @@ export default function ChatComposer({ )} {!hasQuestionPanel &&
- {isUserScrolledUp && hasMessages && ( -
- -
- )} {showFileDropdown && filteredFiles.length > 0 && (
{filteredFiles.map((file, index) => ( diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index 55029c58..78d212b9 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { memo, useCallback, useMemo, useRef } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import type { Dispatch, RefObject, SetStateAction } from 'react'; import type { ChatMessage } from '../../types/types'; @@ -117,37 +117,45 @@ function ChatMessagesPane({ selectedProject, }: ChatMessagesPaneProps) { const { t } = useTranslation('chat'); - const messageKeyMapRef = useRef>(new WeakMap()); - const allocatedKeysRef = useRef>(new Set()); - const generatedMessageKeyCounterRef = useRef(0); const groupedVisibleMessages = useMemo( () => groupConsecutiveTools(visibleMessages, Boolean(showThinking)), [visibleMessages, showThinking], ); - // Keep keys stable across prepends so existing MessageComponent instances retain local state. - const getMessageKey = useCallback((message: ChatMessage) => { - const existingKey = messageKeyMapRef.current.get(message); - if (existingKey) { - return existingKey; + // Stable, deterministic keys for the messages rendered this pass. + // + // `normalizedToChatMessages` rebuilds fresh ChatMessage objects on every store + // update, so caching keys by object identity (or via a cross-render allocation + // Set) minted a brand-new key for the *same* logical message on each prepend — + // remounting the whole list, which disconnects the scroll-restore anchor and + // reflows heights, jumping the viewport to the bottom. Deriving keys purely + // from this render's ordered messages (intrinsic key, disambiguated by + // occurrence index on collision) yields the same key for the same message + // order, so React preserves existing DOM nodes and component state on prepend. + const messageKeyMap = useMemo(() => { + const keys = new WeakMap(); + const occurrences = new Map(); + const assign = (message: ChatMessage) => { + const intrinsicKey = getIntrinsicMessageKey(message) ?? 'message-generated'; + const seen = occurrences.get(intrinsicKey) ?? 0; + occurrences.set(intrinsicKey, seen + 1); + keys.set(message, seen === 0 ? intrinsicKey : `${intrinsicKey}__${seen}`); + }; + for (const item of groupedVisibleMessages) { + if (isToolGroupItem(item)) { + item.messages.forEach(assign); + } else { + assign(item); + } } + return keys; + }, [groupedVisibleMessages]); - const intrinsicKey = getIntrinsicMessageKey(message); - let candidateKey = intrinsicKey; - - if (!candidateKey || allocatedKeysRef.current.has(candidateKey)) { - do { - generatedMessageKeyCounterRef.current += 1; - candidateKey = intrinsicKey - ? `${intrinsicKey}-${generatedMessageKeyCounterRef.current}` - : `message-generated-${generatedMessageKeyCounterRef.current}`; - } while (allocatedKeysRef.current.has(candidateKey)); - } - - allocatedKeysRef.current.add(candidateKey); - messageKeyMapRef.current.set(message, candidateKey); - return candidateKey; - }, []); + const getMessageKey = useCallback( + (message: ChatMessage) => + messageKeyMap.get(message) ?? getIntrinsicMessageKey(message) ?? 'message-generated', + [messageKeyMap], + ); return (