Follow the thread's bottom, and offer a jump back to it

Sending a message while scrolled up left it out of sight: the thread only
ever anchored its bottom, so nothing brought the view down. The column now
follows new messages while it sits at the bottom, stays put while it's
scrolled up, and always brings a message the user just sent into view.
While the thread sits above its bottom, "Jump to latest" floats over it.

Whether the thread is at its bottom is read from where the content ends
rather than from `scrollTop`, whose sign a reversed column reports
differently across engines. Content that grows after it lands (an image,
a wider window) is followed too, so the newest message never slips under
the composer.
This commit is contained in:
2026-10-23 16:03:37 +01:00
parent d9aea8e9c8
commit 55fc5ee97b
5 changed files with 408 additions and 31 deletions
@@ -1,15 +1,17 @@
import { useMemo, useRef, type RefObject } from "react";
import { useMemo, type RefObject } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import type {
BotMessageWithAttachments,
BotWithLastMessage,
} from "@/src/types";
import useMessageSelection from "../(hooks)/use-message-selection";
import useThreadScroll from "../(hooks)/use-thread-scroll";
import groupMessages from "../(utils)/group-messages";
import BotThinkingRow from "./bot-thinking-row";
import MessageGroupBlock from "./message-group-block";
import OlderMessagesLoader from "./older-messages-loader";
import QuoteSelectionButton from "./quote-selection-button";
import ScrollToBottomButton from "./scroll-to-bottom-button";
type Props = {
bot: BotWithLastMessage;
@@ -51,7 +53,9 @@ type Props = {
* While the bot works on a reply or a task, a status row sits under the
* last one. Text selected in a message can be quoted in the composer.
* Older messages load as it scrolls to the top; the reversed column keeps
* the view where it was as they land above it.
* the view where it was as they land above it. The thread follows new
* messages while it sits at its bottom, stays put while it's scrolled up
* and then offers a button to get back down.
*/
export default function MessageThread({
bot,
@@ -64,42 +68,52 @@ export default function MessageThread({
older,
}: Props) {
const groups = useMemo(() => groupMessages({ messages }), [messages]);
const ref = useRef<HTMLDivElement>(null);
const { selection, clear } = useMessageSelection({ ref });
const scroll = useThreadScroll({ messages });
const { selection, clear } = useMessageSelection({ ref: scroll.column_ref });
return (
<Stack
componentRef={ref as RefObject<HTMLDivElement>}
className="grow min-h-0 gap-0! items-stretch overflow-y-auto flex-col-reverse">
<Stack className="relative grow min-h-0 gap-0! items-stretch">
<Stack
role="log"
aria-live="polite"
aria-label={`Conversation with ${bot.name}`}
className="w-full max-w-container mx-auto gap-4! items-stretch px-3 md:px-6 pt-6 pb-6"
componentRef={scroll.column_ref as RefObject<HTMLDivElement>}
className="grow min-h-0 gap-0! items-stretch overflow-y-auto flex-col-reverse"
onScroll={scroll.on_scroll}
>
{older?.has_more ? (
<OlderMessagesLoader
loading={older.loading}
error={older.error}
onLoad={older.loadOlder}
/>
) : null}
{groups.map((group) => (
<MessageGroupBlock
key={group.key}
<Stack
componentRef={scroll.content_ref as RefObject<HTMLDivElement>}
role="log"
aria-live="polite"
aria-label={`Conversation with ${bot.name}`}
className="w-full max-w-container mx-auto gap-4! items-stretch px-3 md:px-6 pt-6 pb-6"
>
{older?.has_more ? (
<OlderMessagesLoader
loading={older.loading}
error={older.error}
onLoad={older.loadOlder}
/>
) : null}
{groups.map((group) => (
<MessageGroupBlock
key={group.key}
bot={bot}
group={group}
/>
))}
<BotThinkingRow
bot={bot}
group={group}
progress={progress}
reply_progress={reply_progress}
awaiting_reply={
messages[messages.length - 1]?.author == "user"
}
receiving={receiving}
has_task={has_task}
/>
))}
<BotThinkingRow
bot={bot}
progress={progress}
reply_progress={reply_progress}
awaiting_reply={messages[messages.length - 1]?.author == "user"}
receiving={receiving}
has_task={has_task}
/>
</Stack>
</Stack>
{scroll.jump_visible ? (
<ScrollToBottomButton onClick={scroll.jump_to_bottom} />
) : null}
{selection && onQuote ? (
<QuoteSelectionButton
selection={selection}
@@ -0,0 +1,28 @@
import Button from "@moduletrace/twui/layout/Button";
import { ArrowDown } from "lucide-react";
type Props = {
onClick: () => void;
};
/**
* "Jump to latest", floating over the bottom of the thread while it sits
* above it: a bot's reply that landed out of sight, or a long scroll back
* through the conversation.
*/
export default function ScrollToBottomButton({ onClick }: Props) {
return (
<Button
type="button"
color="secondary"
title="Jump to the latest message"
aria-label="Jump to the latest message"
variant="outlined"
onClick={onClick}
className="absolute bottom-3 left-1/2 z-20 shrink-0 -translate-x-1/2 gap-1.5! px-2.5! py-1.5! rounded-default-sm border border-solid border-hairline bg-surface shadow-popover text-xs font-bold text-ink hover:bg-sunken focus-visible:ring-2 focus-visible:ring-ink"
>
<ArrowDown size={13} aria-hidden />
Jump to latest
</Button>
);
}