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