diff --git a/apps/frontend/src/renderer/components/Insights.tsx b/apps/frontend/src/renderer/components/Insights.tsx index 9c0098f8..f380ec48 100644 --- a/apps/frontend/src/renderer/components/Insights.tsx +++ b/apps/frontend/src/renderer/components/Insights.tsx @@ -105,10 +105,36 @@ export function Insights({ projectId }: InsightsProps) { const [creatingTask, setCreatingTask] = useState(null); const [taskCreated, setTaskCreated] = useState>(new Set()); const [showSidebar, setShowSidebar] = useState(true); + const [isUserAtBottom, setIsUserAtBottom] = useState(true); + const [viewportEl, setViewportEl] = useState(null); - const messagesEndRef = useRef(null); const textareaRef = useRef(null); - const scrollAreaViewportRef = useRef(null); + + // Scroll threshold in pixels - user is considered "at bottom" if within this distance + const SCROLL_BOTTOM_THRESHOLD = 100; + + // Check if user is near the bottom of scroll area + const checkIfAtBottom = useCallback((viewport: HTMLElement) => { + const { scrollTop, scrollHeight, clientHeight } = viewport; + return scrollHeight - scrollTop - clientHeight <= SCROLL_BOTTOM_THRESHOLD; + }, []); + + // Handle scroll events to track user position + const handleScroll = useCallback(() => { + if (viewportEl) { + setIsUserAtBottom(checkIfAtBottom(viewportEl)); + } + }, [viewportEl, checkIfAtBottom]); + + // Set up scroll listener and check initial position when viewport becomes available + useEffect(() => { + if (viewportEl) { + // Check initial scroll position + setIsUserAtBottom(checkIfAtBottom(viewportEl)); + viewportEl.addEventListener('scroll', handleScroll, { passive: true }); + return () => viewportEl.removeEventListener('scroll', handleScroll); + } + }, [viewportEl, handleScroll, checkIfAtBottom]); // Load session and set up listeners on mount useEffect(() => { @@ -117,47 +143,14 @@ export function Insights({ projectId }: InsightsProps) { return cleanup; }, [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 + // Smart auto-scroll: only scroll if user is already at bottom + // This allows users to scroll up to read previous messages without being + // yanked back down during streaming responses useEffect(() => { - 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); - } - }; + if (isUserAtBottom && viewportEl) { + viewportEl.scrollTop = viewportEl.scrollHeight; } - - return () => { - if (rafId !== undefined) { - cancelAnimationFrame(rafId); - } - }; - }, [session?.messages, streamingContent]); + }, [session?.messages, streamingContent, isUserAtBottom, viewportEl]); // Focus textarea on mount useEffect(() => { @@ -169,17 +162,13 @@ 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; setInputValue(''); sendMessage(projectId, message); + setIsUserAtBottom(true); // Resume auto-scroll when user sends a message }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -304,7 +293,7 @@ export function Insights({ projectId }: InsightsProps) { {/* Messages */} {messages.length === 0 && !streamingContent ? (
@@ -399,7 +388,6 @@ export function Insights({ projectId }: InsightsProps) {
)} -
)}