fix(frontend): resolve Insights scroll-to-blank-space issue on macOS (ACS-382) (#1535)
* fix(frontend): resolve Insights scroll-to-blank-space issue on macOS (ACS-382)
Fix a bug where large text input in the Insights chat causes the view to
scroll uncontrollably into blank space on macOS.
Root cause was a race condition between smooth scroll animation and DOM
layout updates when large content is rendered. The scrollIntoView with
{behavior: 'smooth'} triggered before ReactMarkdown finished rendering
large content, causing incorrect scroll offset.
Changes:
- Use requestAnimationFrame to defer scroll until after DOM layout
- Replace scrollIntoView with direct scrollTop manipulation for predictable
positioning during streaming
- Add CSS overflow-anchor properties to prevent scroll position jumps
during DOM updates
- Add flex-shrink-0 to input container to prevent layout shifts
- Add proper cleanup for requestAnimationFrame to prevent memory leaks
- Make ref type annotations consistent
Refs: ACS-382, #1403
* refactor: address PR review feedback
- Wrap onViewportRef callback in useCallback to prevent creating new function on every render
- Remove isStreamingRef pattern and read streamingContent directly in useEffect
- Clarify CSS comment that overflow-anchor rules apply globally to all Radix scroll areas
These changes address review feedback while maintaining the same scroll fix behavior.
---------
Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
MessageSquare,
|
||||
@@ -106,8 +106,9 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
const [taskCreated, setTaskCreated] = useState<Set<string>>(new Set());
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const scrollAreaViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Load session and set up listeners on mount
|
||||
useEffect(() => {
|
||||
@@ -117,8 +118,45 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
}, [projectId]);
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
// Uses requestAnimationFrame to ensure DOM layout is complete before scrolling
|
||||
// and direct scrollTop manipulation for more predictable behavior than scrollIntoView
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
const scrollToBottom = () => {
|
||||
const viewport = scrollAreaViewportRef.current;
|
||||
if (viewport) {
|
||||
// Use direct scrollTop manipulation for immediate, predictable scrolling
|
||||
// This avoids race conditions with smooth scrolling animation on macOS
|
||||
viewport.scrollTop = viewport.scrollHeight;
|
||||
} else {
|
||||
// Fallback to scrollIntoView for the sentinel element
|
||||
messagesEndRef.current?.scrollIntoView({ block: 'end' });
|
||||
}
|
||||
};
|
||||
|
||||
// During streaming, use requestAnimationFrame to ensure DOM is updated
|
||||
// After streaming completes, scroll immediately without animation
|
||||
const isStreaming = !!streamingContent;
|
||||
let rafId: number | undefined;
|
||||
if (isStreaming) {
|
||||
rafId = requestAnimationFrame(scrollToBottom);
|
||||
} else {
|
||||
// Small delay for non-streaming updates to allow layout to settle
|
||||
const timeoutId = setTimeout(() => {
|
||||
rafId = requestAnimationFrame(scrollToBottom);
|
||||
}, 10);
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (rafId !== undefined) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafId !== undefined) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}, [session?.messages, streamingContent]);
|
||||
|
||||
// Focus textarea on mount
|
||||
@@ -131,6 +169,11 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
setTaskCreated(new Set());
|
||||
}, [session?.id]);
|
||||
|
||||
// Stable callback for viewport ref to avoid creating new function on each render
|
||||
const handleViewportRef = useCallback((ref: HTMLDivElement | null) => {
|
||||
scrollAreaViewportRef.current = ref;
|
||||
}, []);
|
||||
|
||||
const handleSend = () => {
|
||||
const message = inputValue.trim();
|
||||
if (!message || status.phase === 'thinking' || status.phase === 'streaming') return;
|
||||
@@ -259,7 +302,10 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 px-6 py-4">
|
||||
<ScrollArea
|
||||
className="flex-1 px-6 py-4"
|
||||
onViewportRef={handleViewportRef}
|
||||
>
|
||||
{messages.length === 0 && !streamingContent ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
@@ -359,7 +405,7 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-border p-4">
|
||||
<div className="flex-shrink-0 border-t border-border p-4">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
|
||||
@@ -1093,6 +1093,21 @@ body {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
/* Scroll anchoring - prevent scroll position jumps during DOM updates
|
||||
*
|
||||
* This fixes the issue where large content causes the view to scroll into blank space on macOS.
|
||||
* The rules below apply globally to ALL Radix ScrollArea components across the application.
|
||||
* - Parent viewport disables scroll anchoring to prevent automatic adjustments during DOM updates
|
||||
* - Children re-enable anchoring to allow proper scroll target selection when content settles
|
||||
*/
|
||||
[data-radix-scroll-area-viewport] {
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
[data-radix-scroll-area-viewport] > * {
|
||||
overflow-anchor: auto;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -1712,4 +1727,3 @@ body {
|
||||
[data-ui-scale="190"] { font-size: 30.4px; }
|
||||
[data-ui-scale="195"] { font-size: 31.2px; }
|
||||
[data-ui-scale="200"] { font-size: 32px; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user