diff --git a/ui/providers/BottomDrawer/containers/LogViewer/index.tsx b/ui/providers/BottomDrawer/containers/LogViewer/index.tsx index 82c82002..7c664b81 100644 --- a/ui/providers/BottomDrawer/containers/LogViewer/index.tsx +++ b/ui/providers/BottomDrawer/containers/LogViewer/index.tsx @@ -40,6 +40,11 @@ interface Props { const LogViewerContainer: React.FC = ({ sessionId, source, toolbarPrefix, toolbarActions }) => { const parentRef = useRef(null); + const [scrollElement, setScrollElement] = useState(null); + const scrollRefCallback = useCallback((node: HTMLDivElement | null) => { + parentRef.current = node; + setScrollElement(node); + }, []); const [showTimestamps, setShowTimestamps] = useState(false); const [showSources, setShowSources] = useState(false); const [showLineNumbers, setShowLineNumbers] = useState(false); @@ -153,6 +158,76 @@ const LogViewerContainer: React.FC = ({ sessionId, source, toolbarPrefix, } }, [follow, filteredLineCount, version]); + // Keep refs in sync for use inside ResizeObserver (avoids re-subscribing) + const followRef = useRef(follow); + followRef.current = follow; + const filteredLineCountRef = useRef(filteredLineCount); + filteredLineCountRef.current = filteredLineCount; + const filteredEntriesRef = useRef(filteredEntries); + filteredEntriesRef.current = filteredEntries; + + // Re-pin to bottom (or restore position) when the scroll container resizes. + // This covers drawer drag-resize, minimize/re-expand, and fullscreen toggle. + // We store a timestamp (not an index) so the anchor survives buffer eviction. + const savedVisibleTimestampRef = useRef(null); + const lastContainerHeightRef = useRef(0); + + React.useEffect(() => { + if (!scrollElement) return; + + const observer = new ResizeObserver((entries) => { + const rect = entries[0]?.contentRect; + if (!rect) return; + const newHeight = rect.height; + const prevHeight = lastContainerHeightRef.current; + + if (newHeight > 0 && filteredLineCountRef.current > 0) { + if (followRef.current) { + // Follow mode: always pin to bottom + isAutoScrolling.current = true; + rowVirtualizer.scrollToIndex(filteredLineCountRef.current - 1, { align: 'end' }); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + isAutoScrolling.current = false; + }); + }); + } else if (prevHeight === 0 && savedVisibleTimestampRef.current !== null) { + // Restoring from minimized without follow: resolve saved timestamp to current index + const idx = findEntryIndexByTime( + filteredEntriesRef.current, + new Date(savedVisibleTimestampRef.current), + ); + savedVisibleTimestampRef.current = null; + if (idx >= 0) { + isAutoScrolling.current = true; + rowVirtualizer.scrollToIndex(idx, { align: 'start' }); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + isAutoScrolling.current = false; + }); + }); + } + } + } + + // Save the first visible entry's timestamp before the container collapses + if (prevHeight > 0 && newHeight === 0 && !followRef.current) { + const range = rowVirtualizer.range; + if (range) { + const entry = filteredEntriesRef.current[range.startIndex]; + if (entry?.timestamp) { + savedVisibleTimestampRef.current = entry.timestamp; + } + } + } + + lastContainerHeightRef.current = newHeight; + }); + + observer.observe(scrollElement); + return () => observer.disconnect(); + }, [scrollElement, rowVirtualizer]); + // Detect scroll position to engage/disengage follow const handleScroll = useCallback(() => { if (!parentRef.current || isAutoScrolling.current) return; @@ -600,7 +675,7 @@ const LogViewerContainer: React.FC = ({ sessionId, source, toolbarPrefix, ) : ( /* Virtual log list */