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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user