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:
@@ -0,0 +1,162 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
|
||||||
|
import type { BotMessageWithAttachments } from "@/src/types";
|
||||||
|
import {
|
||||||
|
ThreadJumpWindowMs,
|
||||||
|
scrollThreadToBottom,
|
||||||
|
shouldFollowThread,
|
||||||
|
threadOffset,
|
||||||
|
} from "../(utils)/thread-scroll";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The thread's messages, oldest first, pending ones included.
|
||||||
|
*/
|
||||||
|
messages: BotMessageWithAttachments[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the thread's bottom in view: a chat sitting at the bottom follows
|
||||||
|
* every new message, a chat scrolled up stays where it is (except for a
|
||||||
|
* message the user just sent, which brings the thread to it) and offers a
|
||||||
|
* button to get back down. Whether it sits at the bottom is read from where
|
||||||
|
* the content ends, not from `scrollTop`, whose sign a reversed column
|
||||||
|
* reports differently across engines. Content that grows while the thread
|
||||||
|
* is at the bottom (images landing, a wider window) is followed too.
|
||||||
|
*/
|
||||||
|
export default function useThreadScroll({ messages }: Params) {
|
||||||
|
const column_ref = useRef<HTMLDivElement>(null);
|
||||||
|
const content_ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [jump_visible, setJumpVisible] = useState(false);
|
||||||
|
/** Whether the bottom is being held, so nothing fights the user's scroll. */
|
||||||
|
const following = useRef(true);
|
||||||
|
/**
|
||||||
|
* A pressed jump is still animating, so the scrolling it causes is its
|
||||||
|
* own. It ends when the thread arrives, when a scroll moves away from
|
||||||
|
* the bottom (the user took over), or after the window, whichever first.
|
||||||
|
*/
|
||||||
|
const jump = useRef({ active: false, closest: 0, until: 0 });
|
||||||
|
const previous = useRef<{
|
||||||
|
last_id?: number | string | null;
|
||||||
|
count: number;
|
||||||
|
}>({ count: 0 });
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
const column = column_ref.current;
|
||||||
|
const content = content_ref.current;
|
||||||
|
|
||||||
|
if (!column || !content) return null;
|
||||||
|
|
||||||
|
return threadOffset({
|
||||||
|
content_bottom: content.getBoundingClientRect().bottom,
|
||||||
|
column_bottom: column.getBoundingClientRect().bottom,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The user's own scrolling: it decides whether the thread follows. */
|
||||||
|
const onScroll = () => {
|
||||||
|
const offset = measure();
|
||||||
|
|
||||||
|
if (!offset) return;
|
||||||
|
|
||||||
|
if (jump.current.active) {
|
||||||
|
const animating =
|
||||||
|
Date.now() < jump.current.until &&
|
||||||
|
offset.distance <= jump.current.closest;
|
||||||
|
|
||||||
|
jump.current.closest = offset.distance;
|
||||||
|
jump.current.active = animating;
|
||||||
|
|
||||||
|
if (animating) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
following.current = offset.near_bottom;
|
||||||
|
setJumpVisible(!offset.near_bottom);
|
||||||
|
};
|
||||||
|
|
||||||
|
const jumpToBottom = () => {
|
||||||
|
const column = column_ref.current;
|
||||||
|
|
||||||
|
if (!column) return;
|
||||||
|
|
||||||
|
const reduced = Boolean(
|
||||||
|
globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches,
|
||||||
|
);
|
||||||
|
|
||||||
|
jump.current = {
|
||||||
|
active: !reduced,
|
||||||
|
closest: Number.POSITIVE_INFINITY,
|
||||||
|
until: Date.now() + ThreadJumpWindowMs,
|
||||||
|
};
|
||||||
|
following.current = true;
|
||||||
|
setJumpVisible(false);
|
||||||
|
|
||||||
|
scrollThreadToBottom({
|
||||||
|
element: column,
|
||||||
|
behavior: reduced ? "auto" : "smooth",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const column = column_ref.current;
|
||||||
|
const newest = messages[messages.length - 1];
|
||||||
|
const before = previous.current;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list was replaced (a new session, the first render) when the
|
||||||
|
* message that was last is gone.
|
||||||
|
*/
|
||||||
|
const kept_last_message =
|
||||||
|
before.count > 0 &&
|
||||||
|
messages.some((message) => message.id == before.last_id);
|
||||||
|
|
||||||
|
previous.current = { last_id: newest?.id, count: messages.length };
|
||||||
|
|
||||||
|
if (!column) return;
|
||||||
|
|
||||||
|
const follow = shouldFollowThread({
|
||||||
|
near_bottom: following.current,
|
||||||
|
newest_author: newest?.author,
|
||||||
|
kept_last_message,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!follow) {
|
||||||
|
setJumpVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
following.current = true;
|
||||||
|
jump.current.active = false;
|
||||||
|
setJumpVisible(false);
|
||||||
|
scrollThreadToBottom({ element: column });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const column = column_ref.current;
|
||||||
|
const content = content_ref.current;
|
||||||
|
|
||||||
|
if (!column || !content) return;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A message that grows after it lands (an image, a widened window)
|
||||||
|
* keeps the bottom in view while the thread is there, and leaves a
|
||||||
|
* chat scrolled up alone.
|
||||||
|
*/
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (!following.current) return;
|
||||||
|
|
||||||
|
scrollThreadToBottom({ element: column });
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(content);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
column_ref,
|
||||||
|
content_ref: content_ref as RefObject<HTMLDivElement>,
|
||||||
|
jump_visible,
|
||||||
|
jump_to_bottom: jumpToBottom,
|
||||||
|
on_scroll: onScroll,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 Stack from "@moduletrace/twui/layout/Stack";
|
||||||
import type {
|
import type {
|
||||||
BotMessageWithAttachments,
|
BotMessageWithAttachments,
|
||||||
BotWithLastMessage,
|
BotWithLastMessage,
|
||||||
} from "@/src/types";
|
} from "@/src/types";
|
||||||
import useMessageSelection from "../(hooks)/use-message-selection";
|
import useMessageSelection from "../(hooks)/use-message-selection";
|
||||||
|
import useThreadScroll from "../(hooks)/use-thread-scroll";
|
||||||
import groupMessages from "../(utils)/group-messages";
|
import groupMessages from "../(utils)/group-messages";
|
||||||
import BotThinkingRow from "./bot-thinking-row";
|
import BotThinkingRow from "./bot-thinking-row";
|
||||||
import MessageGroupBlock from "./message-group-block";
|
import MessageGroupBlock from "./message-group-block";
|
||||||
import OlderMessagesLoader from "./older-messages-loader";
|
import OlderMessagesLoader from "./older-messages-loader";
|
||||||
import QuoteSelectionButton from "./quote-selection-button";
|
import QuoteSelectionButton from "./quote-selection-button";
|
||||||
|
import ScrollToBottomButton from "./scroll-to-bottom-button";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
bot: BotWithLastMessage;
|
bot: BotWithLastMessage;
|
||||||
@@ -51,7 +53,9 @@ type Props = {
|
|||||||
* While the bot works on a reply or a task, a status row sits under the
|
* 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.
|
* 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
|
* 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({
|
export default function MessageThread({
|
||||||
bot,
|
bot,
|
||||||
@@ -64,14 +68,18 @@ export default function MessageThread({
|
|||||||
older,
|
older,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const groups = useMemo(() => groupMessages({ messages }), [messages]);
|
const groups = useMemo(() => groupMessages({ messages }), [messages]);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const scroll = useThreadScroll({ messages });
|
||||||
const { selection, clear } = useMessageSelection({ ref });
|
const { selection, clear } = useMessageSelection({ ref: scroll.column_ref });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Stack className="relative grow min-h-0 gap-0! items-stretch">
|
||||||
<Stack
|
<Stack
|
||||||
componentRef={ref as RefObject<HTMLDivElement>}
|
componentRef={scroll.column_ref as RefObject<HTMLDivElement>}
|
||||||
className="grow min-h-0 gap-0! items-stretch overflow-y-auto flex-col-reverse">
|
className="grow min-h-0 gap-0! items-stretch overflow-y-auto flex-col-reverse"
|
||||||
|
onScroll={scroll.on_scroll}
|
||||||
|
>
|
||||||
<Stack
|
<Stack
|
||||||
|
componentRef={scroll.content_ref as RefObject<HTMLDivElement>}
|
||||||
role="log"
|
role="log"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-label={`Conversation with ${bot.name}`}
|
aria-label={`Conversation with ${bot.name}`}
|
||||||
@@ -95,11 +103,17 @@ export default function MessageThread({
|
|||||||
bot={bot}
|
bot={bot}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
reply_progress={reply_progress}
|
reply_progress={reply_progress}
|
||||||
awaiting_reply={messages[messages.length - 1]?.author == "user"}
|
awaiting_reply={
|
||||||
|
messages[messages.length - 1]?.author == "user"
|
||||||
|
}
|
||||||
receiving={receiving}
|
receiving={receiving}
|
||||||
has_task={has_task}
|
has_task={has_task}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
{scroll.jump_visible ? (
|
||||||
|
<ScrollToBottomButton onClick={scroll.jump_to_bottom} />
|
||||||
|
) : null}
|
||||||
{selection && onQuote ? (
|
{selection && onQuote ? (
|
||||||
<QuoteSelectionButton
|
<QuoteSelectionButton
|
||||||
selection={selection}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
ThreadBottomSlack,
|
||||||
|
shouldFollowThread,
|
||||||
|
threadOffset,
|
||||||
|
} from "./thread-scroll";
|
||||||
|
|
||||||
|
describe("threadOffset", () => {
|
||||||
|
test("content whose bottom edge is the column's is at the bottom", () => {
|
||||||
|
expect(
|
||||||
|
threadOffset({ content_bottom: 800, column_bottom: 800 }),
|
||||||
|
).toEqual({ distance: 0, near_bottom: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("content ending just short of the column's bottom is at the bottom", () => {
|
||||||
|
expect(
|
||||||
|
threadOffset({ content_bottom: 780, column_bottom: 800 }),
|
||||||
|
).toEqual({ distance: 0, near_bottom: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a small offset past the bottom still counts as being there", () => {
|
||||||
|
const offset = threadOffset({
|
||||||
|
content_bottom: 800 + ThreadBottomSlack - 1,
|
||||||
|
column_bottom: 800,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(offset.near_bottom).toBe(true);
|
||||||
|
expect(offset.distance).toBe(ThreadBottomSlack - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one pixel past the slack is not the bottom anymore", () => {
|
||||||
|
const offset = threadOffset({
|
||||||
|
content_bottom: 800 + ThreadBottomSlack + 1,
|
||||||
|
column_bottom: 800,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(offset.near_bottom).toBe(false);
|
||||||
|
expect(offset.distance).toBe(ThreadBottomSlack + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scrolled up by a long way is far from the bottom", () => {
|
||||||
|
expect(
|
||||||
|
threadOffset({ content_bottom: 4800, column_bottom: 800 }),
|
||||||
|
).toEqual({ distance: 4000, near_bottom: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the slack is configurable", () => {
|
||||||
|
expect(
|
||||||
|
threadOffset({
|
||||||
|
content_bottom: 1000,
|
||||||
|
column_bottom: 800,
|
||||||
|
slack: 200,
|
||||||
|
}).near_bottom,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldFollowThread", () => {
|
||||||
|
test("a chat at the bottom follows a bot's reply", () => {
|
||||||
|
expect(
|
||||||
|
shouldFollowThread({
|
||||||
|
near_bottom: true,
|
||||||
|
newest_author: "bot",
|
||||||
|
kept_last_message: true,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a chat scrolled up stays put when a bot's reply lands", () => {
|
||||||
|
expect(
|
||||||
|
shouldFollowThread({
|
||||||
|
near_bottom: false,
|
||||||
|
newest_author: "bot",
|
||||||
|
kept_last_message: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a message the user sent always brings the thread to it", () => {
|
||||||
|
expect(
|
||||||
|
shouldFollowThread({
|
||||||
|
near_bottom: false,
|
||||||
|
newest_author: "user",
|
||||||
|
kept_last_message: true,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a list that replaced the old one opens at its bottom", () => {
|
||||||
|
expect(
|
||||||
|
shouldFollowThread({
|
||||||
|
near_bottom: false,
|
||||||
|
newest_author: "bot",
|
||||||
|
kept_last_message: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Pixels from the bottom that still count as being at it, so a mouse wheel
|
||||||
|
* tick or a rounding difference doesn't hide the latest message.
|
||||||
|
*/
|
||||||
|
export const ThreadBottomSlack = 48;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a smooth jump to the bottom runs, in milliseconds. The scrolling
|
||||||
|
* it causes is ignored for that long (so the button doesn't flicker back
|
||||||
|
* mid-animation), and the window ends on its own however it lands.
|
||||||
|
*/
|
||||||
|
export const ThreadJumpWindowMs = 800;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the thread's content sits below the bottom of its column, in
|
||||||
|
* pixels, and whether that still counts as being at the bottom. It reads
|
||||||
|
* the two rects rather than `scrollTop`, whose sign a reversed column
|
||||||
|
* (`flex-col-reverse`) reports differently across engines: the content's
|
||||||
|
* bottom edge is the column's when the thread is at its bottom, and past it
|
||||||
|
* by however far the thread has been scrolled up.
|
||||||
|
*/
|
||||||
|
export function threadOffset({
|
||||||
|
content_bottom,
|
||||||
|
column_bottom,
|
||||||
|
slack = ThreadBottomSlack,
|
||||||
|
}: {
|
||||||
|
content_bottom: number;
|
||||||
|
column_bottom: number;
|
||||||
|
slack?: number;
|
||||||
|
}) {
|
||||||
|
const distance = content_bottom - column_bottom;
|
||||||
|
const at_bottom = distance <= slack;
|
||||||
|
|
||||||
|
return {
|
||||||
|
distance: at_bottom && distance < 0 ? 0 : distance,
|
||||||
|
near_bottom: at_bottom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the thread should show a change of its messages, which keeps the
|
||||||
|
* bottom in view while the user is there: it follows while at the bottom, a
|
||||||
|
* message the user sent always counts, and a list that replaced the old one
|
||||||
|
* (a new session, a first render) opens at its bottom. A chat scrolled up
|
||||||
|
* stays where it is when a bot answers.
|
||||||
|
*/
|
||||||
|
export function shouldFollowThread({
|
||||||
|
near_bottom,
|
||||||
|
newest_author,
|
||||||
|
kept_last_message,
|
||||||
|
}: {
|
||||||
|
near_bottom: boolean;
|
||||||
|
newest_author?: string | null;
|
||||||
|
kept_last_message: boolean;
|
||||||
|
}) {
|
||||||
|
if (!kept_last_message) return true;
|
||||||
|
if (newest_author == "user") return true;
|
||||||
|
|
||||||
|
return near_bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts the thread at its bottom, animated or at once. A reversed column
|
||||||
|
* clamps a past-the-end `scrollTop` back onto its bottom, so the same call
|
||||||
|
* serves either direction.
|
||||||
|
*/
|
||||||
|
export function scrollThreadToBottom({
|
||||||
|
element,
|
||||||
|
behavior = "auto",
|
||||||
|
}: {
|
||||||
|
element: HTMLElement;
|
||||||
|
behavior?: ScrollBehavior;
|
||||||
|
}) {
|
||||||
|
element.scrollTo({ top: element.scrollHeight, behavior });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user