Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 23 additions & 67 deletions src/components/chat/hooks/useChatSessionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,24 +344,13 @@ export function useChatSessionState({
[hasMoreMessages, isLoadingMoreMessages, selectedProject, selectedSession, sessionStore],
);

const handleScroll = useCallback(async () => {
const handleScroll = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;

const nearBottom = isNearBottom();
setIsUserScrolledUp(!nearBottom);

if (!allMessagesLoadedRef.current) {
const scrolledNearTop = container.scrollTop < 100;
if (!scrolledNearTop) { topLoadLockRef.current = false; return; }
if (topLoadLockRef.current) {
if (container.scrollTop > 20) topLoadLockRef.current = false;
return;
}
const didLoad = await loadOlderMessages(container);
if (didLoad) topLoadLockRef.current = true;
}
}, [isNearBottom, loadOlderMessages]);
}, [isNearBottom]);

useLayoutEffect(() => {
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;
Expand All @@ -383,47 +372,12 @@ export function useChatSessionState({
setIsUserScrolledUp(false);
}, [selectedProject?.projectId, selectedSession?.id]);

// Initial scroll to bottom — robust to lazy content reflow.
// The previous implementation fired one scrollToBottom() at +200ms and
// cleared the pending flag. When markdown blocks, code highlighting, or
// images finished rendering after that window, scrollHeight grew but
// nothing re-anchored the viewport, leaving the chat tab visually
// "scrolled way up" with the latest assistant message off-screen.
//
// This version re-scrolls every animation frame while scrollHeight is
// still growing, capped at ~1s (60 frames) or 3 consecutive stable
// frames. Cancels cleanly on session change via the pending flag.
// Initial scroll to bottom
useEffect(() => {
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return;
if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; }
if (searchScrollActiveRef.current) { pendingInitialScrollRef.current = false; return; }

const container = scrollContainerRef.current;
let frame = 0;
let lastHeight = 0;
let stableCount = 0;
let rafId = 0;

const tick = () => {
if (!pendingInitialScrollRef.current || !scrollContainerRef.current) return;
container.scrollTop = container.scrollHeight;
if (container.scrollHeight === lastHeight) {
stableCount++;
} else {
stableCount = 0;
lastHeight = container.scrollHeight;
}
frame++;
if (stableCount < 3 && frame < 60) {
rafId = requestAnimationFrame(tick);
} else {
pendingInitialScrollRef.current = false;
}
};
rafId = requestAnimationFrame(tick);
return () => {
if (rafId) cancelAnimationFrame(rafId);
};
pendingInitialScrollRef.current = false;
if (!searchScrollActiveRef.current) setTimeout(() => scrollToBottom(), 200);
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);

// Main session loading effect — store-based
Expand Down Expand Up @@ -736,23 +690,10 @@ export function useChatSessionState({
}
}, [currentSessionId, isLoading, processingSessions, selectedSession?.id]);

// "Load all" overlay
const prevLoadingRef = useRef(false);
// "Load more" buttons — show whenever there are more messages
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]);
setShowLoadAllOverlay(hasMoreMessages && !allMessagesLoaded);
}, [hasMoreMessages, allMessagesLoaded]);

const loadAllMessages = useCallback(async () => {
if (!selectedSession || !selectedProject) return;
Expand Down Expand Up @@ -812,6 +753,20 @@ export function useChatSessionState({
setVisibleMessageCount((prev) => prev + 100);
}, []);

const loadMoreMessages = useCallback(async () => {
topLoadLockRef.current = false;
const container = scrollContainerRef.current;
if (!container) return;
setIsLoadingMoreMessages(true);
try {
await loadOlderMessages(container);
} catch (error) {
console.error('[useChatSessionState] loadMoreMessages failed:', error);
} finally {
setIsLoadingMoreMessages(false);
}
}, [loadOlderMessages]);

return {
chatMessages,
addMessage,
Expand All @@ -835,6 +790,7 @@ export function useChatSessionState({
visibleMessages,
loadEarlierMessages,
loadAllMessages,
loadMoreMessages,
allMessagesLoaded,
isLoadingAllMessages,
loadAllJustFinished,
Expand Down
1 change: 1 addition & 0 deletions src/components/chat/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export interface ChatInterfaceProps {
showRawParameters?: boolean;
showThinking?: boolean;
autoScrollToBottom?: boolean;
showScrollNavigation?: boolean;
sendByCtrlEnter?: boolean;
externalMessageUpdate?: number;
newSessionTrigger?: number;
Expand Down
23 changes: 20 additions & 3 deletions src/components/chat/view/ChatInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useSessionStore } from '../../../stores/useSessionStore';
import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer';
import CommandResultModal from './subcomponents/CommandResultModal';
import ScrollNavigation from './subcomponents/ScrollNavigation';


type PendingViewSession = {
Expand All @@ -40,6 +41,7 @@ function ChatInterface({
showRawParameters,
showThinking,
autoScrollToBottom,
showScrollNavigation,
sendByCtrlEnter,
externalMessageUpdate,
newSessionTrigger,
Expand Down Expand Up @@ -110,10 +112,10 @@ function ChatInterface({
visibleMessages,
loadEarlierMessages,
loadAllMessages,
loadMoreMessages,
allMessagesLoaded,
isLoadingAllMessages,
loadAllJustFinished,
showLoadAllOverlay,
claudeStatus,
setClaudeStatus,
createDiff,
Expand Down Expand Up @@ -309,7 +311,20 @@ function ChatInterface({
return (
<PermissionContext.Provider value={permissionContextValue}>
<div className="flex h-full flex-col">
<ChatMessagesPane
<div className="relative flex-1">
{showScrollNavigation && (
<ScrollNavigation
scrollContainerRef={scrollContainerRef}
chatMessages={chatMessages}
loadAllMessages={loadAllMessages}
allMessagesLoaded={allMessagesLoaded}
hasMoreMessages={hasMoreMessages}
totalMessages={totalMessages}
sessionMessagesCount={chatMessages.length}
/>
)}
<div className="absolute inset-0">
<ChatMessagesPane
scrollContainerRef={scrollContainerRef}
onWheel={handleScroll}
onTouchMove={handleScroll}
Expand Down Expand Up @@ -344,10 +359,10 @@ function ChatInterface({
visibleMessages={visibleMessages}
loadEarlierMessages={loadEarlierMessages}
loadAllMessages={loadAllMessages}
loadMoreMessages={loadMoreMessages}
allMessagesLoaded={allMessagesLoaded}
isLoadingAllMessages={isLoadingAllMessages}
loadAllJustFinished={loadAllJustFinished}
showLoadAllOverlay={showLoadAllOverlay}
createDiff={createDiff}
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
Expand All @@ -357,6 +372,8 @@ function ChatInterface({
showThinking={showThinking}
selectedProject={selectedProject}
/>
</div>
</div>

<ChatComposer
pendingPermissionRequests={pendingPermissionRequests}
Expand Down
64 changes: 31 additions & 33 deletions src/components/chat/view/subcomponents/ChatMessagesPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,10 @@ interface ChatMessagesPaneProps {
visibleMessages: ChatMessage[];
loadEarlierMessages: () => void;
loadAllMessages: () => void;
loadMoreMessages: () => void;
allMessagesLoaded: boolean;
isLoadingAllMessages: boolean;
loadAllJustFinished: boolean;
showLoadAllOverlay: boolean;
createDiff: any;
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
Expand Down Expand Up @@ -98,10 +98,10 @@ export default function ChatMessagesPane({
visibleMessages,
loadEarlierMessages,
loadAllMessages,
loadMoreMessages,
allMessagesLoaded,
isLoadingAllMessages,
loadAllJustFinished,
showLoadAllOverlay,
createDiff,
onFileOpen,
onShowSettings,
Expand Down Expand Up @@ -145,7 +145,7 @@ export default function ChatMessagesPane({
ref={scrollContainerRef}
onWheel={onWheel}
onTouchMove={onTouchMove}
className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
className="relative h-full space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
>
{isLoadingSessionMessages && chatMessages.length === 0 ? (
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
Expand Down Expand Up @@ -180,55 +180,53 @@ export default function ChatMessagesPane({
/>
) : (
<>
{/* Loading indicator for older messages (hide when load-all is active) */}
{isLoadingMoreMessages && !isLoadingAllMessages && !allMessagesLoaded && (
{/* Loading indicator for older messages */}
{(isLoadingMoreMessages || isLoadingAllMessages) && !allMessagesLoaded && (
<div className="py-3 text-center text-gray-500 dark:text-gray-400">
<div className="flex items-center justify-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-gray-400" />
<p className="text-sm">{t('session.loading.olderMessages')}</p>
<p className="text-sm">{isLoadingAllMessages ? t('session.messages.loadingAll') : t('session.loading.olderMessages')}</p>
</div>
</div>
)}

{/* Indicator showing there are more messages to load (hide when all loaded) */}
{hasMoreMessages && !isLoadingMoreMessages && !allMessagesLoaded && (
{/* "Load more" buttons — show when there are more messages */}
{hasMoreMessages && !isLoadingMoreMessages && !isLoadingAllMessages && !allMessagesLoaded && (
<div className="border-b border-gray-200 py-2 text-center text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400">
{totalMessages > 0 && (
<span>
{t('session.messages.showingOf', { shown: sessionMessagesCount, total: totalMessages })}{' '}
<span className="text-xs">{t('session.messages.scrollToLoad')}</span>
</span>
)}
</div>
)}

{/* Floating "Load all messages" overlay */}
{(showLoadAllOverlay || isLoadingAllMessages || loadAllJustFinished) && (
<div className="pointer-events-none sticky top-2 z-20 flex justify-center">
{loadAllJustFinished ? (
<div className="flex items-center space-x-2 rounded-full bg-green-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg dark:bg-green-500">
<div className="mt-1.5 flex items-center justify-center gap-3">
<button
className="flex items-center space-x-1 rounded-full bg-gray-200 px-3 py-1 text-xs font-medium text-gray-700 shadow transition-all duration-200 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
onClick={loadMoreMessages}
>
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
<span>{t('session.messages.allLoaded')}</span>
</div>
) : (
<span>{t('session.messages.loadMore', { count: 20 })}</span>
</button>
<button
className="pointer-events-auto flex items-center space-x-2 rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg transition-all duration-200 hover:scale-105 hover:bg-blue-700 disabled:cursor-wait disabled:opacity-75 dark:bg-blue-500 dark:hover:bg-blue-600"
className="flex items-center space-x-1 rounded-full bg-blue-600 px-3 py-1 text-xs font-medium text-white shadow transition-all duration-200 hover:scale-105 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
onClick={loadAllMessages}
disabled={isLoadingAllMessages}
>
{isLoadingAllMessages && (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white/30 border-t-white" />
)}
<span>
{isLoadingAllMessages
? t('session.messages.loadingAll')
: <>{t('session.messages.loadAll')} {totalMessages > 0 && `(${totalMessages})`}</>
}
</span>
<span>{t('session.messages.loadAll')} {totalMessages > 0 && `(${totalMessages})`}</span>
</button>
)}
</div>
</div>
)}

{/* "All loaded" success indicator */}
{loadAllJustFinished && (
<div className="pointer-events-none sticky top-2 z-20 flex justify-center">
<div className="flex items-center space-x-2 rounded-full bg-green-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg dark:bg-green-500">
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
<span>{t('session.messages.allLoaded')}</span>
</div>
</div>
)}

Expand Down
Loading