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;