From c3d55a7bcdc1a0e0a2b540437094251b5363a1c5 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 19 Jul 2026 21:33:54 +0800 Subject: [PATCH] fix(miner-ui): stick-to-bottom auto-scroll for chat rail (#7229) Scroll the Radix ScrollArea viewport when messages grow or streaming footer content resizes, unless the operator has scrolled away from the bottom. Co-authored-by: Cursor --- ...7229-chat-rail-autoscroll-before-after.svg | 22 ++++ .../src/chat-message-components.test.tsx | 83 +++++++++++++- .../src/components/chat/conversation.tsx | 29 ++--- .../src/components/chat/message-list.tsx | 101 +++++++++++++----- apps/loopover-miner-ui/src/lib/chat-scroll.ts | 13 +++ .../src/components/scroll-area.tsx | 32 +++--- 6 files changed, 222 insertions(+), 58 deletions(-) create mode 100644 apps/loopover-miner-ui/docs/7229-chat-rail-autoscroll-before-after.svg create mode 100644 apps/loopover-miner-ui/src/lib/chat-scroll.ts diff --git a/apps/loopover-miner-ui/docs/7229-chat-rail-autoscroll-before-after.svg b/apps/loopover-miner-ui/docs/7229-chat-rail-autoscroll-before-after.svg new file mode 100644 index 0000000000..d3eecb65bf --- /dev/null +++ b/apps/loopover-miner-ui/docs/7229-chat-rail-autoscroll-before-after.svg @@ -0,0 +1,22 @@ + + + #7229 chat rail stick-to-bottom + + + Before + New / streaming content appends below the fold + + [ older turn visible ] + & + “ new answer / stream off-screen + operator must scroll manually + + + After + Pinned when near bottom; respects scroll-up + + & + latest message in view + streaming text stays pinned + scrolled-up history: no yank + diff --git a/apps/loopover-miner-ui/src/chat-message-components.test.tsx b/apps/loopover-miner-ui/src/chat-message-components.test.tsx index 5612a57bcb..46c99a5ac7 100644 --- a/apps/loopover-miner-ui/src/chat-message-components.test.tsx +++ b/apps/loopover-miner-ui/src/chat-message-components.test.tsx @@ -68,7 +68,8 @@ describe("MessageList (#7081) — message list is a polite live region for compl expect(screen.getByText("first answer")).toBeTruthy(); // The next completed turn adds exactly one more — never a burst, because streaming chunks (rendered by the - // separate StreamingText outside this list) never mutate `messages`. + // footer StreamingText outside this live region but inside the same ScrollArea — #7229) never mutate + // `messages`. const reAnswered: ChatMessage[] = [ ...answered, { id: "u2", role: "user", content: "and now?", timestamp: "2026-07-16T08:00:02.000Z" }, @@ -86,6 +87,86 @@ describe("MessageList (#7081) — message list is a polite live region for compl }); }); +describe("MessageList (#7229) — stick-to-bottom auto-scroll", () => { + function mockViewportMetrics( + viewport: HTMLElement, + metrics: { scrollHeight: number; clientHeight: number; scrollTop?: number }, + ) { + Object.defineProperty(viewport, "scrollHeight", { configurable: true, get: () => metrics.scrollHeight }); + Object.defineProperty(viewport, "clientHeight", { configurable: true, get: () => metrics.clientHeight }); + let top = metrics.scrollTop ?? 0; + Object.defineProperty(viewport, "scrollTop", { + configurable: true, + get: () => top, + set: (value: number) => { + top = value; + }, + }); + } + + function getViewport(container: HTMLElement): HTMLElement { + const viewport = container.querySelector("[data-radix-scroll-area-viewport]"); + if (!(viewport instanceof HTMLElement)) throw new Error("missing ScrollArea viewport"); + return viewport; + } + + it("pins scrollTop to the bottom when messages grow while already near the bottom", () => { + const question: ChatMessage[] = [{ id: "u1", role: "user", content: "q1", timestamp: "2026-07-16T08:00:00.000Z" }]; + const { container, rerender } = render(); + const viewport = getViewport(container); + mockViewportMetrics(viewport, { scrollHeight: 400, clientHeight: 200, scrollTop: 200 }); + + const answered: ChatMessage[] = [ + ...question, + { id: "a1", role: "assistant", content: "a1", timestamp: "2026-07-16T08:00:01.000Z" }, + ]; + mockViewportMetrics(viewport, { scrollHeight: 600, clientHeight: 200, scrollTop: viewport.scrollTop }); + rerender(); + expect(viewport.scrollTop).toBe(400); // scrollHeight - clientHeight + }); + + it("does not yank scrollTop when the operator has scrolled away from the bottom", () => { + const question: ChatMessage[] = [{ id: "u1", role: "user", content: "q1", timestamp: "2026-07-16T08:00:00.000Z" }]; + const { container, rerender } = render(); + const viewport = getViewport(container); + mockViewportMetrics(viewport, { scrollHeight: 600, clientHeight: 200, scrollTop: 0 }); + viewport.dispatchEvent(new Event("scroll")); + + const answered: ChatMessage[] = [ + ...question, + { id: "a1", role: "assistant", content: "a1", timestamp: "2026-07-16T08:00:01.000Z" }, + ]; + mockViewportMetrics(viewport, { scrollHeight: 800, clientHeight: 200, scrollTop: 0 }); + rerender(); + expect(viewport.scrollTop).toBe(0); + }); + + it("keeps pinning while a streaming footer grows (ResizeObserver path)", async () => { + const { container, rerender } = render( + hi} />, + ); + const viewport = getViewport(container); + mockViewportMetrics(viewport, { scrollHeight: 300, clientHeight: 200, scrollTop: 100 }); + + rerender( + hi there, a longer streamed answer} + />, + ); + mockViewportMetrics(viewport, { scrollHeight: 500, clientHeight: 200, scrollTop: viewport.scrollTop }); + // Re-trigger layout effect via composing toggle (footer already remounted). + rerender( + hi there, a longer streamed answer} + />, + ); + expect(viewport.scrollTop).toBe(300); + }); +}); + describe("MessageBubble (#6515) — role-color + avatar branches", () => { const base: ChatMessage = { id: "x", diff --git a/apps/loopover-miner-ui/src/components/chat/conversation.tsx b/apps/loopover-miner-ui/src/components/chat/conversation.tsx index 9d49f74acd..901149d55b 100644 --- a/apps/loopover-miner-ui/src/components/chat/conversation.tsx +++ b/apps/loopover-miner-ui/src/components/chat/conversation.tsx @@ -191,18 +191,23 @@ export function ChatConversation({

Chat

- - {streaming && activeSource ? ( -
- - {ASSISTANT_NAME.slice(0, 2).toUpperCase()} - - -
- ) : null} + + + {ASSISTANT_NAME.slice(0, 2).toUpperCase()} + + +
+ ) : null + } + />
diff --git a/apps/loopover-miner-ui/src/components/chat/message-list.tsx b/apps/loopover-miner-ui/src/components/chat/message-list.tsx index c5a468b03b..94ae1c6051 100644 --- a/apps/loopover-miner-ui/src/components/chat/message-list.tsx +++ b/apps/loopover-miner-ui/src/components/chat/message-list.tsx @@ -1,5 +1,7 @@ +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { ScrollArea } from "@loopover/ui-kit/components/scroll-area"; import { StateBoundary } from "@loopover/ui-kit/components/state-views"; +import { isChatViewportNearBottom, scrollChatViewportToBottom } from "@/lib/chat-scroll"; import { MessageBubble } from "./message-bubble"; import { TypingIndicator } from "./typing-indicator"; import type { ChatMessage } from "./fixtures"; @@ -8,47 +10,88 @@ import type { ChatMessage } from "./fixtures"; // array it's given, wrapping the content in ui-kit's StateBoundary for its own loading/empty/error states // and using ui-kit's ScrollArea (not a raw overflow div) for the viewport. The composing flag surfaces the // TypingIndicator below the list regardless of the message-array state. +// +// #7229: stick-to-bottom auto-scroll on the Radix Viewport — new messages and live footer growth (streaming) +// keep the latest content in view unless the operator has scrolled up to review history. export function MessageList({ messages, isLoading = false, isError = false, composing = false, + footer = null, }: { messages: ChatMessage[]; isLoading?: boolean; isError?: boolean; composing?: boolean; + /** Extra content inside the same ScrollArea viewport (e.g. live StreamingText — #7229). */ + footer?: ReactNode; }) { + const viewportRef = useRef(null); + const contentRef = useRef(null); + const [stickToBottom, setStickToBottom] = useState(true); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + const onScroll = () => { + setStickToBottom(isChatViewportNearBottom(viewport)); + }; + viewport.addEventListener("scroll", onScroll, { passive: true }); + return () => viewport.removeEventListener("scroll", onScroll); + }, []); + + // Pin to bottom when messages grow or the inner content resizes (streaming chunks), if still sticky. + useLayoutEffect(() => { + const viewport = viewportRef.current; + if (!viewport || !stickToBottom) return; + scrollChatViewportToBottom(viewport); + }, [messages.length, composing, footer, stickToBottom]); + + useEffect(() => { + const viewport = viewportRef.current; + const content = contentRef.current; + if (!viewport || !content || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => { + if (stickToBottom) scrollChatViewportToBottom(viewport); + }); + observer.observe(content); + return () => observer.disconnect(); + }, [stickToBottom]); + return ( - - - {/* - #7081: the message list is a polite ARIA live region so assistive tech announces each completed turn - even when the user has moved focus out of the list. `messages` gains a committed entry exactly once per - turn — conversation.tsx appends the finished answer only after StreamingText's per-chunk accumulation - resolves, never mid-stream, and the live StreamingText render lives OUTSIDE this list — so each new - message announces once, never once-per-streaming-chunk. `aria-relevant="additions"` keeps it to newly - appended messages; StateBoundary's own loading/empty/error status/alert regions are separate and - untouched (an added message can't reach here in those branches anyway). - */} -
    - {messages.map((message) => ( -
  1. - -
  2. - ))} -
-
- {composing ? : null} + +
+ + {/* + #7081: the message list is a polite ARIA live region so assistive tech announces each completed turn + even when the user has moved focus out of the list. `messages` gains a committed entry exactly once per + turn — conversation.tsx appends the finished answer only after StreamingText's per-chunk accumulation + resolves, never mid-stream, and the live StreamingText render lives as `footer` inside this same + viewport (#7229) but outside the live region — so each new message announces once, never + once-per-streaming-chunk. `aria-relevant="additions"` keeps it to newly appended messages; + StateBoundary's own loading/empty/error status/alert regions are separate and untouched. + */} +
    + {messages.map((message) => ( +
  1. + +
  2. + ))} +
+
+ {composing ? : null} + {footer} +
); } diff --git a/apps/loopover-miner-ui/src/lib/chat-scroll.ts b/apps/loopover-miner-ui/src/lib/chat-scroll.ts new file mode 100644 index 0000000000..9f6ab44427 --- /dev/null +++ b/apps/loopover-miner-ui/src/lib/chat-scroll.ts @@ -0,0 +1,13 @@ +/** Distance from the bottom (px) that still counts as "pinned" for stick-to-bottom auto-scroll (#7229). */ +export const CHAT_NEAR_BOTTOM_PX = 80; + +export function isChatViewportNearBottom( + viewport: Pick, + thresholdPx = CHAT_NEAR_BOTTOM_PX, +): boolean { + return viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= thresholdPx; +} + +export function scrollChatViewportToBottom(viewport: HTMLElement): void { + viewport.scrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight); +} diff --git a/packages/loopover-ui-kit/src/components/scroll-area.tsx b/packages/loopover-ui-kit/src/components/scroll-area.tsx index 797fd8452f..747a6ad535 100644 --- a/packages/loopover-ui-kit/src/components/scroll-area.tsx +++ b/packages/loopover-ui-kit/src/components/scroll-area.tsx @@ -3,22 +3,22 @@ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; import { cn } from "../utils"; -const ScrollArea = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - {children} - - - - -)); +type ScrollAreaProps = React.ComponentPropsWithoutRef & { + /** Optional ref to the scrollable Viewport (not the Root). Used by chat stick-to-bottom (#7229). */ + viewportRef?: React.Ref>; +}; + +const ScrollArea = React.forwardRef, ScrollAreaProps>( + ({ className, children, viewportRef, ...props }, ref) => ( + + + {children} + + + + + ), +); ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; const ScrollBar = React.forwardRef<