Streaming Auto-Scroll Disrupts Reading Focus in WebChat UI
WebChat's continuous auto-scroll during token-by-token streaming pushes users out of their reading position when they scroll up to review earlier content.
π Symptoms
Technical Manifestations
The issue manifests during streaming conversations in OpenClaw Control UI or WebChat. Users experience involuntary viewport jumps while attempting to read historical content.
- Viewport ejection: When scrolling up to review a partial response or earlier message, the viewport forcibly returns to the bottom as new tokens arrive.
- Interrupted reading flow: Users must repeatedly scroll back up to maintain their reading position during active streaming sessions.
- Context switching fatigue: Frequent scroll interventions disrupt cognitive flow and reduce conversation comprehension.
CLI/DevTools Diagnostic Output
To observe the problematic behavior, inspect the message container’s scroll properties during streaming:
// Open browser DevTools console during active streaming
setInterval(() => {
const container = document.querySelector('[data-message-container]') ||
document.querySelector('.message-list');
if (container) {
console.log({
scrollTop: container.scrollTop,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
isAtBottom: container.scrollHeight - container.scrollTop - container.clientHeight < 50
});
}
}, 500);
// During streaming, observe: scrollTop continuously jumps to scrollHeight
// even when user has scrolled up (scrollTop > 0)
Visual Behavior Pattern
Timeline of streaming with broken auto-scroll:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Token arrives: [βββββββββββββββββββββββββββββ] scrollHeight grows
User scrolls up to read earlier content
β
Token arrives: [ββββββββββββββββββββββββββββ] viewport forcibly jumps to bottom
β
User scrolls up again
β
Token arrives: [ββββββββββββββββββββββββββββ] viewport forcibly jumps to bottom
β
[Repetition continues until streaming completes]
Expected behavior:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
User at bottom β auto-scroll follows (desired)
User scrolled up β viewport preserved (desired)
π§ Root Cause
Architectural Analysis
The root cause lies in the message container’s scroll synchronization logic. The implementation unconditionally anchors the viewport to the bottom of the content on every update, violating the principle of preserving user agency over their scroll position.
Code Path Analysis
The problematic code pattern typically appears in the streaming content handler:
// Typical flawed implementation found in WebChat component
function onStreamingUpdate(newContent) {
appendContent(newContent);
// PROBLEMATIC: Unconditional scroll-to-bottom
messageContainer.scrollTop = messageContainer.scrollHeight;
// This runs on EVERY token arrival (~50-200ms intervals)
// regardless of user's current reading position
}
Failure Sequence
- Initial state: User has scrolled up to read earlier content;
scrollTop > 0 - Token arrival: Streaming handler receives new content chunk
- Content growth:
scrollHeightincreases by content height - Forced scroll:
scrollTopis set to newscrollHeight - Viewport jump: User is ejected from their reading position
- Repeat: Steps 2-5 cycle until streaming completes
State Variables Missing
The fix requires tracking a single boolean state:
// Required state variable (missing in current implementation)
let userIsNearBottom = true; // or use: let userInitiatedScroll = false;
// On user scroll event (passive listener)
messageContainer.addEventListener('scroll', () => {
const threshold = 100; // pixels from bottom
userIsNearBottom =
(messageContainer.scrollHeight - messageContainer.scrollTop -
messageContainer.clientHeight) < threshold;
}, { passive: true });
Relevant Source Locations
- WebChat streaming handler: Likely in
src/components/ChatMessageList.tsxor equivalent - Scroll synchronization:
useEffecthook that depends on message array - Container ref: The scrollable
divelement requiring therefattachment
π οΈ Step-by-Step Fix
Phase 1: Introduce Scroll Position State
Add a ref to track whether the user is currently viewing the bottom of the container:
// Before (broken implementation)
import React, { useRef, useEffect } from 'react';
function ChatMessageList({ messages }) {
const containerRef = useRef(null);
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [messages]);
return (
<div ref={containerRef} className="message-list">
{messages.map(msg => <Message key={msg.id} {...msg} />)}
</div>
);
}
// After (smart scroll implementation)
import React, { useRef, useEffect, useCallback } from 'react';
function ChatMessageList({ messages }) {
const containerRef = useRef(null);
const isNearBottomRef = useRef(true);
const SCROLL_THRESHOLD = 100; // pixels
// Track user's scroll position
const handleScroll = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
isNearBottomRef.current = distanceFromBottom <= SCROLL_THRESHOLD;
}, []);
// Scroll to bottom ONLY if user is already near bottom
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// Only auto-scroll if user was at the bottom
if (isNearBottomRef.current) {
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth'
});
}
// If user scrolled up, DO NOT interrupt - preserve viewport
}, [messages]);
return (
<div
ref={containerRef}
className="message-list"
onScroll={handleScroll}
>
{messages.map(msg => <Message key={msg.id} {...msg} />)}
</div>
);
}
Phase 2: Handle Streaming-Specific Edge Cases
For high-frequency streaming updates, debounce scroll position checks:
// Enhanced implementation with debouncing for streaming
import React, { useRef, useEffect, useCallback, useState } from 'react';
function ChatMessageList({ messages, isStreaming }) {
const containerRef = useRef(null);
const isNearBottomRef = useRef(true);
const scrollCheckTimeoutRef = useRef(null);
const [containerHeight, setContainerHeight] = useState(0);
const SCROLL_THRESHOLD = 100;
const handleScroll = useCallback(() => {
// Debounce to reduce overhead during rapid streaming
if (scrollCheckTimeoutRef.current) {
clearTimeout(scrollCheckTimeoutRef.current);
}
scrollCheckTimeoutRef.current = setTimeout(() => {
const container = containerRef.current;
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
isNearBottomRef.current = distanceFromBottom <= SCROLL_THRESHOLD;
}, 16); // ~60fps
}, []);
// Scroll to bottom with streaming-optimized behavior
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// During active streaming, scroll immediately (no animation)
// When idle or user at bottom, use smooth scroll
if (isNearBottomRef.current) {
if (isStreaming) {
container.scrollTop = container.scrollHeight;
} else {
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth'
});
}
}
}, [messages, isStreaming]);
// Update container height on resize
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const resizeObserver = new ResizeObserver(() => {
setContainerHeight(container.clientHeight);
});
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, []);
return (
<div
ref={containerRef}
className="message-list"
onScroll={handleScroll}
>
{messages.map(msg => <Message key={msg.id} {...msg} />)}
</div>
);
}
Phase 3: CSS Enhancement (Optional)
Ensure the container has proper overflow handling:
/* message-list container styles */
.message-list {
overflow-y: auto;
overflow-x: hidden;
/* Ensure container has defined height constraints */
height: 100%;
max-height: calc(100vh - 200px); /* Adjust based on layout */
/* iOS momentum scrolling support */
-webkit-overflow-scrolling: touch;
/* Prevent layout shift during scroll */
contain: strict;
}
π§ͺ Verification
Test Case 1: Auto-Scroll at Bottom (Default Behavior)
Test Steps:
1. Start a new conversation
2. Send a message that triggers a streaming response
3. DO NOT scroll - remain at the bottom of the message list
Expected Result:
β Viewport automatically follows new content to the bottom
β Smooth or instant scroll on each token arrival
β No jarring jumps or stutters
Exit Criteria: Viewport remains locked to bottom throughout streaming
Test Case 2: Preserve Position When Scrolled Up (Critical Fix)
Test Steps:
1. Send a message triggering a multi-line streaming response
2. While AI is streaming, scroll UP to read the user prompt
3. Continue reading while tokens continue to arrive
4. Observe viewport behavior
Expected Result:
β Viewport stays at the scrolled-up position
β New content appends below without viewport movement
β Reading focus is preserved throughout
Exit Criteria: User's scroll position remains unchanged despite new content
Test Case 3: Return-to-Bottom After Reading
Test Steps:
1. During streaming, scroll up to read earlier content
2. Wait for streaming to complete (or send another message)
3. Click a "Jump to bottom" button (if implemented)
4. Or: Scroll back to the bottom manually
Expected Result:
β When user scrolls to bottom, auto-follow resumes
β "Jump to bottom" button scrolls immediately to latest content
Exit Criteria: isNearBottomRef resets correctly on manual scroll
DevTools Verification Commands
// Verify state tracking works correctly
// Run in browser console during streaming:
const container = document.querySelector('.message-list');
let scrollEvents = 0;
let scrollTos = 0;
container.addEventListener('scroll', () => {
scrollEvents++;
const isNearBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < 100;
console.log(`Scroll event #${scrollEvents}: isNearBottom=${isNearBottom}`);
});
// Manually scroll up and observe console
// Should see isNearBottom=false
// New tokens should NOT trigger scrollTop changes
Automated Test Snippet
// Component unit test example (Jest + React Testing Library)
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ChatMessageList } from './ChatMessageList';
test('preserves scroll position when user scrolls up during streaming', async () => {
const mockMessages = generateTestMessages(50);
render(<ChatMessageList messages={mockMessages} isStreaming={true} />);
const container = screen.getByRole('listbox'); // or appropriate selector
// Scroll to bottom
container.scrollTop = container.scrollHeight;
// Simulate user scrolling up
fireEvent.scroll(container, { target: { scrollTop: 0 } });
// Add new message (simulating token arrival)
const newMessages = [...mockMessages, createNewMessage()];
// Check that scrollTop did not change programmatically
const scrollTopBefore = container.scrollTop;
render(<ChatMessageList messages={newMessages} isStreaming={true} />);
// Critical assertion: scroll position preserved
expect(container.scrollTop).toBe(scrollTopBefore);
});
β οΈ Common Pitfalls
Pitfall 1: Using scrollTop Instead of scrollTo()
Problem: Setting scrollTop directly causes instant jumps. During streaming, this creates a jarring experience.
// β Problematic
container.scrollTop = container.scrollHeight;
// β Recommended
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth'
});
Pitfall 2: Stale Closure on scroll Event Handler
Problem: If handleScroll is defined without useCallback or with incorrect dependencies, it may capture stale isNearBottomRef.
// β Problematic - ref captured in closure at creation time
const handleScroll = () => {
isNearBottomRef.current = calculateIsNearBottom();
};
// β Correct - useCallback with proper deps
const handleScroll = useCallback(() => {
isNearBottomRef.current = calculateIsNearBottom();
}, []); // Refs don't need to be in deps
Pitfall 3: Ignoring ResizeObserver for Dynamic Heights
Problem: If the message container or parent elements have dynamic heights (e.g., expanding input area), the scroll calculation becomes inaccurate.
Solution: Track container height changes:
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver(() => {
// Recalculate near-bottom state on any size change
updateNearBottomState();
});
observer.observe(container);
return () => observer.disconnect();
}, []);
Pitfall 4: Threshold Too Small on High-DPI Displays
Problem: A 100px threshold may be insufficient on displays with different scaling factors.
Solution: Use percentage-based or scale-aware threshold:
// β Fixed pixel threshold
const SCROLL_THRESHOLD = 100;
// β Proportional threshold
const SCROLL_THRESHOLD_PERCENT = 0.1; // 10% of visible area
const getThreshold = () => container.clientHeight * SCROLL_THRESHOLD_PERCENT;
Pitfall 5: Forgetting Passive Scroll Listeners
Problem: Non-passive scroll listeners block main thread and degrade scroll performance.
// β Blocking listener
container.addEventListener('scroll', handleScroll);
// β Non-blocking passive listener
container.addEventListener('scroll', handleScroll, { passive: true });
Pitfall 6: Mobile iOS Momentum Scrolling
Problem: On iOS, -webkit-overflow-scrolling: touch may cause delayed scroll position updates.
Solution: Ensure CSS includes momentum scrolling:
.message-list {
-webkit-overflow-scrolling: touch;
overflow-y: auto;
}
Pitfall 7: Edge Case - Empty Container
Problem: scrollHeight - scrollTop - clientHeight produces NaN or incorrect values when container is empty or has zero height.
Solution: Add defensive checks:
const isNearBottom = container.scrollHeight > 0 &&
container.clientHeight > 0 &&
(container.scrollHeight - container.scrollTop -
container.clientHeight) <= SCROLL_THRESHOLD;
π Related Errors
Related Issues in OpenClaw Ecosystem
- Chat container scroll-jumping during rapid token updates
Symptom: Visible stutter or flicker when streaming at high speed (>50 tokens/sec)
Distinction: Performance issue vs. UX behavior issue - WebChat message list memory growth with long conversations
Symptom: Browser tab memory increases unbounded during extended sessions
Distinction: Memory leak vs. scroll behavior - Scroll position reset on message edit or regeneration
Symptom: Viewport jumps unexpectedly when editing previous AI responses
Distinction: Different code path but similar root cause (unconditional scroll)
Historical Context
The auto-scroll behavior was likely implemented as a straightforward solution to ensure users always see the latest content. However, this design does not account for the reading patterns of users who reference previous content while waiting for streaming to completeβa common workflow when:
- Reviewing the original prompt while awaiting response
- Comparing multiple AI responses side-by-side
- Reading code examples that the user wants to understand before continuing
Cross-Reference: Similar Implementations
| Application | Auto-Scroll Strategy |
|---|---|
| Discord | “Smart scroll”: follows only at bottom, indicator appears when scrolled up |
| Slack | Follows at bottom, pauses on manual scroll, resumes on “Jump to latest” |
| Telegram | Unconditional follow with dismissible “New messages” pill |
| GitHub | “Stay on this file” option, respects scroll position on navigation |
| VS Code Terminal | Scroll lock toggle, respects user scroll position |
Suggested Future Enhancements
- Visual indicator: Display "New messages below" badge when scrolled up during active streaming
- Keyboard shortcut: Add
Ctrl+EndorCmd+Downto jump to latest - Scroll lock toggle: Allow users to explicitly disable auto-follow
- Preferences setting: Persist scroll behavior preference per user