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:
StillKnotKnown
2026-01-26 15:17:59 +02:00
committed by GitHub
parent f289107b8d
commit 496b2b96a5
2 changed files with 67 additions and 7 deletions
@@ -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}
+15 -1
View File
@@ -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; }