diff --git a/apps/frontend/src/renderer/components/Insights.tsx b/apps/frontend/src/renderer/components/Insights.tsx index 3f3a9b5f..9c0098f8 100644 --- a/apps/frontend/src/renderer/components/Insights.tsx +++ b/apps/frontend/src/renderer/components/Insights.tsx @@ -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>(new Set()); const [showSidebar, setShowSidebar] = useState(true); - const messagesEndRef = useRef(null); - const textareaRef = useRef(null); + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + const scrollAreaViewportRef = useRef(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) { {/* Messages */} - + {messages.length === 0 && !streamingContent ? (
@@ -359,7 +405,7 @@ export function Insights({ projectId }: InsightsProps) { {/* Input */} -
+