feat(web): agent auto-scroll (follow mode + jump-to-latest button) and bottom padding on terminal and agent

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.
This commit is contained in:
tao.chen
2026-07-06 18:46:47 +08:00
parent 8bb0bdfb4c
commit 35b1bc4357
2 changed files with 51 additions and 12 deletions
+50 -11
View File
@@ -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(() => { useEffect(() => {
const root = scrollRootRef.current; const root = scrollRootRef.current;
const sentinel = sentinelRef.current; if (!root) return;
if (!root || !sentinel) return;
const viewport = root.querySelector( const viewport = root.querySelector(
"[data-radix-scroll-area-viewport]", "[data-radix-scroll-area-viewport]",
) as HTMLDivElement | null; ) as HTMLDivElement | null;
if (!viewport) return; 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 = useEffect(() => {
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; const root = scrollRootRef.current;
if (distanceFromBottom <= 80) { const sentinel = sentinelRef.current;
sentinel.scrollIntoView({ behavior: "smooth", block: "end" }); if (!root || !sentinel) return;
} if (!isAtBottomRef.current) return; // user scrolled up; let them read
sentinel.scrollIntoView({ behavior: "smooth", block: "end" });
}, [coalesced, inflight?.text, localUserMessage]); }, [coalesced, inflight?.text, localUserMessage]);
const scrollToBottom = useCallback(() => {
sentinelRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
});
}, []);
const handleSend = () => { const handleSend = () => {
const content = input.trim(); const content = input.trim();
if (!content || !workspaceId || inflight) return; if (!content || !workspaceId || inflight) return;
@@ -255,8 +282,8 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) {
<StatusPill status={acpStatus} /> <StatusPill status={acpStatus} />
</header> </header>
<ScrollArea ref={scrollRootRef} className="flex-1"> <ScrollArea ref={scrollRootRef} className="relative flex-1">
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3 p-3 pb-6">
{messages.length === 0 && {messages.length === 0 &&
!localUserMessage && !localUserMessage &&
!inflight && !inflight &&
@@ -285,8 +312,20 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) {
{err.message} {err.message}
</div> </div>
))} ))}
<div ref={sentinelRef} /> {/* Sentinel: when auto-scrolled, the scroll lands here. h-8 gives
the last message a little breathing room above the composer. */}
<div ref={sentinelRef} className="h-8 shrink-0" />
</div> </div>
{!isAtBottom && (
<button
type="button"
onClick={scrollToBottom}
aria-label="Scroll to bottom"
className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full border border-border bg-background/90 px-3 py-1 text-xs text-muted-foreground shadow-md backdrop-blur hover:bg-accent hover:text-foreground"
>
Jump to latest
</button>
)}
</ScrollArea> </ScrollArea>
<div className="flex h-12 shrink-0 items-center gap-2 border-t border-border bg-background px-3"> <div className="flex h-12 shrink-0 items-center gap-2 border-t border-border bg-background px-3">
@@ -257,7 +257,7 @@ export function TerminalPanel({ workspaceId, shellId }: TerminalPanelProps) {
)} )}
</div> </div>
)} )}
<div ref={containerRef} className="min-h-0 flex-1 p-2" /> <div ref={containerRef} className="min-h-0 flex-1 px-2 pt-2 pb-8" />
</section> </section>
); );
} }