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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user