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) { )}
)} -
+
); }