diff --git a/packages/react-ui/src/components/AgentInterface/Thread.tsx b/packages/react-ui/src/components/AgentInterface/Thread.tsx
index 9b5b6fd03..001b6ccd8 100644
--- a/packages/react-ui/src/components/AgentInterface/Thread.tsx
+++ b/packages/react-ui/src/components/AgentInterface/Thread.tsx
@@ -27,6 +27,7 @@ import { MarkDownRenderer } from "../MarkDownRenderer";
import { AgentInterfaceTooltip } from "./_shared/AgentInterfaceTooltip";
import { GalleryHorizontalEndIcon } from "./_shared/GalleryHorizontalEndIcon";
import { AmbientLoader } from "./components/AmbientLoader";
+import { ScrollToLatest } from "./components/ScrollToLatest";
import { ResizableSeparator } from "./ResizableSeparator";
import { useDetailedViewResize } from "./useDetailedViewResize";
import { UserMessageContent } from "./UserMessageContent";
@@ -180,6 +181,7 @@ export const ScrollArea = ({
>
{children}
+
);
};
diff --git a/packages/react-ui/src/components/AgentInterface/components/ScrollToLatest.tsx b/packages/react-ui/src/components/AgentInterface/components/ScrollToLatest.tsx
new file mode 100644
index 000000000..4c9701640
--- /dev/null
+++ b/packages/react-ui/src/components/AgentInterface/components/ScrollToLatest.tsx
@@ -0,0 +1,110 @@
+import React, { useCallback, useEffect, useState } from "react";
+
+// Hysteresis: a single cutoff made the arrow blink while streaming content
+// hovered around the line.
+const SHOW_BELOW_PX = 96;
+const HIDE_BELOW_PX = 24;
+
+/**
+ * Bottom of the last message's rendered CONTENT, in viewport coordinates.
+ * The user-message-anchor spacer inflates the last message's own box, so
+ * measure its children instead.
+ */
+function lastContent(scroller: HTMLElement): { el: HTMLElement; bottom: number } | null {
+ const messages = scroller.querySelector(".openui-agent-thread-messages");
+ const last = messages?.lastElementChild as HTMLElement | null;
+ if (!last) return null;
+ const children = Array.from(last.children) as HTMLElement[];
+ if (children.length === 0) return { el: last, bottom: last.getBoundingClientRect().bottom };
+ let el = children[0]!;
+ let bottom = last.getBoundingClientRect().top;
+ for (const child of children) {
+ const r = child.getBoundingClientRect();
+ if (r.bottom > bottom) {
+ bottom = r.bottom;
+ el = child;
+ }
+ }
+ return { el, bottom };
+}
+
+/** Down-arrow above the composer, shown only when content sits below the fold. */
+export const ScrollToLatest = ({
+ scrollRef,
+}: {
+ scrollRef: React.RefObject;
+}) => {
+ const [belowFold, setBelowFold] = useState(false);
+
+ const measure = useCallback(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ const content = lastContent(el);
+ if (!content) {
+ setBelowFold(false);
+ return;
+ }
+ const delta = content.bottom - el.getBoundingClientRect().bottom;
+ setBelowFold((prev) => (prev ? delta > HIDE_BELOW_PX : delta > SHOW_BELOW_PX));
+ }, [scrollRef]);
+
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ measure();
+ el.addEventListener("scroll", measure, { passive: true });
+ return () => el.removeEventListener("scroll", measure);
+ }, [scrollRef, measure]);
+
+ // Content changes fire no scroll events: a thread can load taller than the
+ // viewport at scrollTop 0, and streaming grows below the fold in place.
+ // The scroller is observed too — a viewport resize moves the line content
+ // is measured against without changing the content.
+ useEffect(() => {
+ const el = scrollRef.current;
+ measure();
+ if (!el || typeof ResizeObserver === "undefined") return;
+ const ro = new ResizeObserver(measure);
+ ro.observe(el);
+ const content = el.firstElementChild;
+ if (content) ro.observe(content);
+ return () => ro.disconnect();
+ }, [measure, scrollRef]);
+
+ const jump = useCallback(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ const content = lastContent(el);
+ if (!content) return;
+ // `block: "end"` applies the scroller's scroll-padding-bottom, landing
+ // where the built-in scroll variants land instead of under the fade mask.
+ content.el.scrollIntoView({ block: "end", behavior: "smooth" });
+ }, [scrollRef]);
+
+ if (!belowFold) return null;
+
+ return (
+
+ );
+};
diff --git a/packages/react-ui/src/components/AgentInterface/thread.scss b/packages/react-ui/src/components/AgentInterface/thread.scss
index e5325a748..4394f28c0 100644
--- a/packages/react-ui/src/components/AgentInterface/thread.scss
+++ b/packages/react-ui/src/components/AgentInterface/thread.scss
@@ -228,6 +228,44 @@ $thread-content-max-width: calc(
}
}
+// =============================================================================
+// Scroll-to-latest affordance
+// =============================================================================
+
+.openui-agent-thread-scroll-latest {
+ position: absolute;
+ bottom: cssUtils.$space-m;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 5;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ border: 1px solid cssUtils.$border-default;
+ border-radius: cssUtils.$radius-full;
+ background-color: cssUtils.$background;
+ color: cssUtils.$text-neutral-secondary;
+ cursor: pointer;
+ box-shadow: cssUtils.$shadow-m;
+
+ &:hover {
+ color: cssUtils.$text-neutral-primary;
+ border-color: cssUtils.$border-interactive;
+ }
+
+ &:focus-visible {
+ outline: 2px solid cssUtils.$border-interactive-emphasis;
+ outline-offset: 2px;
+ }
+
+ &__arrow {
+ display: block;
+ }
+}
+
// =============================================================================
// Messages
// =============================================================================
@@ -249,6 +287,7 @@ $thread-content-max-width: calc(
.openui-agent-thread-message-assistant {
width: 100%;
display: flex;
+ align-items: flex-start;
gap: cssUtils.$space-s;
padding-right: $center-align-spacing;
overflow: hidden;