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) {