May 04, 2026

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

  1. Initial state: User has scrolled up to read earlier content; scrollTop > 0
  2. Token arrival: Streaming handler receives new content chunk
  3. Content growth: scrollHeight increases by content height
  4. Forced scroll: scrollTop is set to new scrollHeight
  5. Viewport jump: User is ejected from their reading position
  6. 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.tsx or equivalent
  • Scroll synchronization: useEffect hook that depends on message array
  • Container ref: The scrollable div element requiring the ref attachment

πŸ› οΈ 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;
  • 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

ApplicationAuto-Scroll Strategy
Discord“Smart scroll”: follows only at bottom, indicator appears when scrolled up
SlackFollows at bottom, pauses on manual scroll, resumes on “Jump to latest”
TelegramUnconditional follow with dismissible “New messages” pill
GitHub“Stay on this file” option, respects scroll position on navigation
VS Code TerminalScroll lock toggle, respects user scroll position

Suggested Future Enhancements

  1. Visual indicator: Display "New messages below" badge when scrolled up during active streaming
  2. Keyboard shortcut: Add Ctrl+End or Cmd+Down to jump to latest
  3. Scroll lock toggle: Allow users to explicitly disable auto-follow
  4. Preferences setting: Persist scroll behavior preference per user

Evidence & Sources

This troubleshooting guide was automatically synthesized by the FixClaw Intelligence Pipeline from community discussions.