Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
83 changes: 82 additions & 1 deletion apps/loopover-miner-ui/src/chat-message-components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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(<MessageList messages={question} />);
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(<MessageList messages={answered} />);
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(<MessageList messages={question} />);
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(<MessageList messages={answered} />);
expect(viewport.scrollTop).toBe(0);
});

it("keeps pinning while a streaming footer grows (ResizeObserver path)", async () => {
const { container, rerender } = render(
<MessageList messages={singleMessage} footer={<div data-testid="stream-foot">hi</div>} />,
);
const viewport = getViewport(container);
mockViewportMetrics(viewport, { scrollHeight: 300, clientHeight: 200, scrollTop: 100 });

rerender(
<MessageList
messages={singleMessage}
footer={<div data-testid="stream-foot">hi there, a longer streamed answer</div>}
/>,
);
mockViewportMetrics(viewport, { scrollHeight: 500, clientHeight: 200, scrollTop: viewport.scrollTop });
// Re-trigger layout effect via composing toggle (footer already remounted).
rerender(
<MessageList
messages={singleMessage}
composing
footer={<div data-testid="stream-foot">hi there, a longer streamed answer</div>}
/>,
);
expect(viewport.scrollTop).toBe(300);
});
});

describe("MessageBubble (#6515) — role-color + avatar branches", () => {
const base: ChatMessage = {
id: "x",
Expand Down
29 changes: 17 additions & 12 deletions apps/loopover-miner-ui/src/components/chat/conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,18 +191,23 @@ export function ChatConversation({
<div className="flex h-full flex-col gap-2 p-4">
<p className="font-mono text-token-xs uppercase tracking-[0.2em] text-primary">Chat</p>
<div className="min-h-0 flex-1 overflow-hidden">
<MessageList messages={messages} composing={streaming && awaitingFirstChunk} />
{streaming && activeSource ? (
<div className="flex gap-3 px-3 pt-4" data-testid="chat-streaming-response">
<Avatar className="size-8 shrink-0">
<AvatarFallback>{ASSISTANT_NAME.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<StreamingText
source={activeSource}
className="min-w-0 whitespace-pre-wrap break-words rounded-token-sm bg-muted px-3 py-2 text-token-sm text-foreground"
/>
</div>
) : null}
<MessageList
messages={messages}
composing={streaming && awaitingFirstChunk}
footer={
streaming && activeSource ? (
<div className="flex gap-3 px-3 pt-4" data-testid="chat-streaming-response">
<Avatar className="size-8 shrink-0">
<AvatarFallback>{ASSISTANT_NAME.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<StreamingText
source={activeSource}
className="min-w-0 whitespace-pre-wrap break-words rounded-token-sm bg-muted px-3 py-2 text-token-sm text-foreground"
/>
</div>
) : null
}
/>
</div>
<ChatComposer onSubmit={handleSubmit} disabled={streaming} />
</div>
Expand Down
101 changes: 72 additions & 29 deletions apps/loopover-miner-ui/src/components/chat/message-list.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(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 (
<ScrollArea className="h-full">
<StateBoundary
isLoading={isLoading}
isError={isError}
isEmpty={messages.length === 0}
loadingTitle="Loading conversation…"
emptyTitle="No messages yet"
emptyDescription="Start the conversation to see messages here."
errorTitle="Couldn't load the conversation"
errorDescription="The conversation source did not respond. Retry, or check back once it has recovered."
>
{/*
#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).
*/}
<ol className="flex flex-col gap-4 p-3" aria-live="polite" aria-relevant="additions">
{messages.map((message) => (
<li key={message.id}>
<MessageBubble message={message} />
</li>
))}
</ol>
</StateBoundary>
{composing ? <TypingIndicator composing authorName="Assistant" /> : null}
<ScrollArea className="h-full" viewportRef={viewportRef}>
<div ref={contentRef}>
<StateBoundary
isLoading={isLoading}
isError={isError}
isEmpty={messages.length === 0}
loadingTitle="Loading conversation…"
emptyTitle="No messages yet"
emptyDescription="Start the conversation to see messages here."
errorTitle="Couldn't load the conversation"
errorDescription="The conversation source did not respond. Retry, or check back once it has recovered."
>
{/*
#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.
*/}
<ol className="flex flex-col gap-4 p-3" aria-live="polite" aria-relevant="additions">
{messages.map((message) => (
<li key={message.id}>
<MessageBubble message={message} />
</li>
))}
</ol>
</StateBoundary>
{composing ? <TypingIndicator composing authorName="Assistant" /> : null}
{footer}
</div>
</ScrollArea>
);
}
13 changes: 13 additions & 0 deletions apps/loopover-miner-ui/src/lib/chat-scroll.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement, "scrollTop" | "scrollHeight" | "clientHeight">,
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);
}
32 changes: 16 additions & 16 deletions packages/loopover-ui-kit/src/components/scroll-area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,22 @@ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";

import { cn } from "../utils";

const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
type ScrollAreaProps = React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
/** Optional ref to the scrollable Viewport (not the Root). Used by chat stick-to-bottom (#7229). */
viewportRef?: React.Ref<React.ElementRef<typeof ScrollAreaPrimitive.Viewport>>;
};

const ScrollArea = React.forwardRef<React.ElementRef<typeof ScrollAreaPrimitive.Root>, ScrollAreaProps>(
({ className, children, viewportRef, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport ref={viewportRef} className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
),
);
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;

const ScrollBar = React.forwardRef<
Expand Down
Loading