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(() => {
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) {
<StatusPill status={acpStatus} />
</header>
<ScrollArea ref={scrollRootRef} className="flex-1">
<div className="flex flex-col gap-3 p-3">
<ScrollArea ref={scrollRootRef} className="relative flex-1">
<div className="flex flex-col gap-3 p-3 pb-6">
{messages.length === 0 &&
!localUserMessage &&
!inflight &&
@@ -285,8 +312,20 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) {
{err.message}
</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>
{!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>
<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 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>
);
}