From 35b1bc4357d6a8f83296b10170833355ff7d85e8 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:46:47 +0800 Subject: [PATCH] feat(web): agent auto-scroll (follow mode + jump-to-latest button) and bottom padding on terminal and agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent (AcpPanel): - Follow state: an isAtBottom ref + state. A scroll listener on the ScrollArea viewport keeps them in sync. The auto-scroll effect only fires scrollIntoView when the ref is true, so the user can scroll up to read history without the panel snapping back to the bottom. - Jump-to-latest button: when !isAtBottom, an absolute-positioned '↓ Jump to latest' pill appears at the bottom of the ScrollArea; clicking it scrolls back to the bottom and re-enables follow. - Bottom padding: messages container is now 'p-3 pb-6' and the trailing sentinel is 'h-8 shrink-0', so the last bubble has breathing room above the composer and isn't visually clipped. Terminal (TerminalPanel): - xterm container is now 'px-2 pt-2 pb-8' so the bottom row of the terminal isn't covered by the hint/status bar above the composer (or, in the standalone TerminalPanel, by the tab bar). Both panels: extra bottom padding so the last line of content is never hidden by an adjacent control. --- web/src/components/acp/AcpPanel.tsx | 61 +++++++++++++++---- web/src/components/terminal/TerminalPanel.tsx | 2 +- 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/web/src/components/acp/AcpPanel.tsx b/web/src/components/acp/AcpPanel.tsx index b364e41..20bc82c 100644 --- a/web/src/components/acp/AcpPanel.tsx +++ b/web/src/components/acp/AcpPanel.tsx @@ -164,23 +164,50 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) { }; }, []); + // Track "follow" state. If the user is near the bottom, new content + // pulls them along. If they scroll up, we stop following so they can + // read history; a back-to-bottom button appears. The ref lets the + // auto-scroll effect read the freshest value without depending on + // the state (which would cause a re-fire loop when scrollIntoView + // itself emits a scroll event). + const isAtBottomRef = useRef(true); + const [isAtBottom, setIsAtBottom] = useState(true); + useEffect(() => { const root = scrollRootRef.current; - const sentinel = sentinelRef.current; - if (!root || !sentinel) return; - + if (!root) return; const viewport = root.querySelector( "[data-radix-scroll-area-viewport]", ) as HTMLDivElement | null; if (!viewport) return; + const handler = () => { + const distance = + viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; + const atBottom = distance <= 80; + isAtBottomRef.current = atBottom; + setIsAtBottom(atBottom); + }; + viewport.addEventListener("scroll", handler); + handler(); // sync initial state + return () => viewport.removeEventListener("scroll", handler); + }, []); - const distanceFromBottom = - viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; - if (distanceFromBottom <= 80) { - sentinel.scrollIntoView({ behavior: "smooth", block: "end" }); - } + useEffect(() => { + const root = scrollRootRef.current; + const sentinel = sentinelRef.current; + if (!root || !sentinel) return; + if (!isAtBottomRef.current) return; // user scrolled up; let them read + + sentinel.scrollIntoView({ behavior: "smooth", block: "end" }); }, [coalesced, inflight?.text, localUserMessage]); + const scrollToBottom = useCallback(() => { + sentinelRef.current?.scrollIntoView({ + behavior: "smooth", + block: "end", + }); + }, []); + const handleSend = () => { const content = input.trim(); if (!content || !workspaceId || inflight) return; @@ -255,8 +282,8 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) { - -
+ +
{messages.length === 0 && !localUserMessage && !inflight && @@ -285,8 +312,20 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) { {err.message}
))} -
+ {/* Sentinel: when auto-scrolled, the scroll lands here. h-8 gives + the last message a little breathing room above the composer. */} +
+ {!isAtBottom && ( + + )}
diff --git a/web/src/components/terminal/TerminalPanel.tsx b/web/src/components/terminal/TerminalPanel.tsx index 2174f40..c7c2a02 100644 --- a/web/src/components/terminal/TerminalPanel.tsx +++ b/web/src/components/terminal/TerminalPanel.tsx @@ -257,7 +257,7 @@ export function TerminalPanel({ workspaceId, shellId }: TerminalPanelProps) { )}
)} -
+
); }