Skip to content
Merged
5 changes: 1 addition & 4 deletions web-ui/src/app/inference/chat/components/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ interface MessageListProps {
isStreaming: boolean;
verifyResponse: (message: Message, originalIndex: number) => void;
messagesContainerRef?: React.RefObject<HTMLDivElement | null>;
messagesEndRef?: React.RefObject<HTMLDivElement | null>;
onEditMessage?: (originalIndex: number, newContent: string) => void;
onRegenerateMessage?: (originalIndex: number) => void;
}
Expand All @@ -40,15 +39,13 @@ export function MessageList({
isStreaming,
verifyResponse,
messagesContainerRef: externalContainerRef,
messagesEndRef: externalEndRef,
onEditMessage,
onRegenerateMessage,
}: MessageListProps) {
const internalContainerRef = useRef<HTMLDivElement>(null);
const internalEndRef = useRef<HTMLDivElement>(null);
const endRef = useRef<HTMLDivElement>(null);

const containerRef = externalContainerRef || internalContainerRef;
const endRef = externalEndRef || internalEndRef;

return (
<div ref={containerRef} className="flex-1 overflow-y-auto overflow-x-hidden p-2 sm:p-4 space-y-3 sm:space-y-4">
Expand Down
76 changes: 42 additions & 34 deletions web-ui/src/app/inference/chat/components/OptimizedChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ export function OptimizedChatPage() {
const [showTopUpModal, setShowTopUpModal] = useState(false);
const [topUpAmount, setTopUpAmount] = useState("");
const [isTopping, setIsTopping] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const isUserScrollingRef = useRef(false);
const isAtBottomRef = useRef(true);

// Initialize chat history hook first - shared across all providers for the same wallet
const chatHistory = useChatHistory({
Expand Down Expand Up @@ -163,7 +163,6 @@ export function OptimizedChatPage() {
setIsStreaming,
setErrorWithTimeout,
isUserScrollingRef,
messagesEndRef,
openConnectModal,
requestDeposit,
});
Expand Down Expand Up @@ -341,50 +340,52 @@ export function OptimizedChatPage() {

// Database will initialize automatically when needed (Dexie.js is lightweight)

// Track user scroll behavior to stop auto-scroll when user manually scrolls up
// Track user scroll behavior with debounce.
// Uses scroll event for position tracking (isAtBottom) and a SCROLL_DEBOUNCE ms
// debounce for isUserScrolling. Because programmatic scrollTo also fires scroll
// events, this effectively throttles auto-scroll during streaming to ~1/SCROLL_DEBOUNCE
// interval — an intentional tradeoff that reduces DOM operations while remaining
// visually smooth. Auto-scroll uses behavior:"instant" to avoid animation conflicts.
useEffect(() => {
const messagesContainer = messagesContainerRef.current;
if (!messagesContainer) return;

let scrollTimeout: ReturnType<typeof setTimeout>;

const handleScroll = () => {
isUserScrollingRef.current = true;
clearTimeout(scrollTimeout);

const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
isAtBottomRef.current = scrollTop + clientHeight >= scrollHeight - CHAT_CONFIG.SCROLL_THRESHOLD;

if (isStreaming || isLoading) {
const isAtBottom = distanceFromBottom <= CHAT_CONFIG.SCROLL_THRESHOLD;
isUserScrollingRef.current = !isAtBottom;
}
scrollTimeout = setTimeout(() => {
isUserScrollingRef.current = false;
}, CHAT_CONFIG.SCROLL_DEBOUNCE);
};

messagesContainer.addEventListener('scroll', handleScroll, { passive: true });
return () => messagesContainer.removeEventListener('scroll', handleScroll);
}, [isStreaming, isLoading]);
return () => {
messagesContainer.removeEventListener('scroll', handleScroll);
clearTimeout(scrollTimeout);
};
}, []);

// Reset scroll tracking when streaming/loading ends
useEffect(() => {
if (!isStreaming && !isLoading) {
isUserScrollingRef.current = false;
}
}, [isStreaming, isLoading]);

useEffect(() => {
const scrollToBottom = () => {
if (isUserScrollingRef.current) return; // Don't scroll if user is manually scrolling
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};

// Check if this is just a verification status update
useEffect(() => {
const isVerificationUpdate = () => {
const prev = previousMessagesRef.current;
if (prev.length !== messages.length) return false;

// Check if only verification fields changed
for (let i = 0; i < messages.length; i++) {
const current = messages[i];
const previous = prev[i];

// If content, role, or timestamp changed, it's not just verification
if (current.content !== previous.content ||
if (current.content !== previous.content ||
current.role !== previous.role ||
current.timestamp !== previous.timestamp ||
current.chatId !== previous.chatId) {
Expand All @@ -394,20 +395,28 @@ export function OptimizedChatPage() {
return true;
};

// Don't auto-scroll if:
// 1. It's just a verification update
// 2. It's a history navigation (loading history)
// 3. User is manually scrolling during streaming
if (!isVerificationUpdate() && !isLoadingHistoryRef.current && !isUserScrollingRef.current) {
const timeoutId = setTimeout(scrollToBottom, CHAT_CONFIG.SCROLL_DELAY);
// Update the ref after scrolling decision
if (isVerificationUpdate() || isLoadingHistoryRef.current) {
previousMessagesRef.current = [...messages];
return () => clearTimeout(timeoutId);
return;
}

// Update the ref even if we don't scroll

// New message added (user sent or assistant started): always scroll
// Content update during streaming: only scroll if user is at bottom
const isNewMessage = messages.length !== previousMessagesRef.current.length;
const shouldScroll = isNewMessage || (isAtBottomRef.current && !isUserScrollingRef.current);

if (shouldScroll) {
requestAnimationFrame(() => {
const container = messagesContainerRef.current;
if (container) {
container.scrollTo({ top: container.scrollHeight, behavior: 'instant' });
isAtBottomRef.current = true;
}
});
}

previousMessagesRef.current = [...messages];
}, [messages, isLoading, isStreaming]);
}, [messages]);


// Remove clearChat function since we removed the Clear Chat button
Expand Down Expand Up @@ -586,7 +595,6 @@ export function OptimizedChatPage() {
isStreaming={isStreaming}
verifyResponse={verifyResponse}
messagesContainerRef={messagesContainerRef}
messagesEndRef={messagesEndRef}
onEditMessage={handleEditMessage}
onRegenerateMessage={handleRegenerateMessage}
/>
Expand Down
1 change: 1 addition & 0 deletions web-ui/src/app/inference/chat/constants/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const CHAT_CONFIG = {
// Scroll behavior
SCROLL_THRESHOLD: 50, // Distance from bottom to consider "near bottom"
SCROLL_DELAY: 100, // Delay before auto-scrolling in ms
SCROLL_DEBOUNCE: 150, // Debounce for user scroll detection; also throttles auto-scroll during streaming

// Message highlighting
HIGHLIGHT_DURATION: 2000, // How long to highlight a message in ms
Expand Down
13 changes: 3 additions & 10 deletions web-ui/src/shared/hooks/useMessageHandling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ interface MessageHandlingConfig {
setIsStreaming: (streaming: boolean) => void;
setErrorWithTimeout: (error: string | null) => void;
isUserScrollingRef: React.RefObject<boolean>;
messagesEndRef: React.RefObject<HTMLDivElement | null>;
openConnectModal?: () => void;
requestDeposit?: () => Promise<void>;
}
Expand All @@ -54,7 +53,6 @@ export function useMessageHandling(config: MessageHandlingConfig) {
setIsStreaming,
setErrorWithTimeout,
isUserScrollingRef,
messagesEndRef,
openConnectModal,
requestDeposit,
} = config;
Expand Down Expand Up @@ -210,13 +208,8 @@ export function useMessageHandling(config: MessageHandlingConfig) {
)
);

setTimeout(() => {
if (!isUserScrollingRef.current) {
messagesEndRef.current?.scrollIntoView({
behavior: "smooth",
});
}
}, 50);
// Auto-scroll is handled by the useEffect in OptimizedChatPage
// that watches messages state changes.
}
} catch {
// Skip invalid JSON
Expand Down Expand Up @@ -301,7 +294,7 @@ export function useMessageHandling(config: MessageHandlingConfig) {
abortControllerRef.current = null;
}
}, [serviceMetadata, setMessages, setIsLoading, setIsStreaming,
setErrorWithTimeout, isUserScrollingRef, messagesEndRef]);
setErrorWithTimeout, isUserScrollingRef]);

const sendMessage = useCallback(async () => {
if (!inputMessage.trim() || !selectedProvider) return;
Expand Down