diff --git a/src/pages/admin/bots/[bot_id]/(hooks)/use-thread-scroll.ts b/src/pages/admin/bots/[bot_id]/(hooks)/use-thread-scroll.ts new file mode 100644 index 0000000..089c05e --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(hooks)/use-thread-scroll.ts @@ -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(null); + const content_ref = useRef(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, + jump_visible, + jump_to_bottom: jumpToBottom, + on_scroll: onScroll, + }; +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx b/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx index c25c2c1..0f57752 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx @@ -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(null); - const { selection, clear } = useMessageSelection({ ref }); + const scroll = useThreadScroll({ messages }); + const { selection, clear } = useMessageSelection({ ref: scroll.column_ref }); return ( - } - 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} > - {older?.has_more ? ( - - ) : null} - {groups.map((group) => ( - } + 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 ? ( + + ) : null} + {groups.map((group) => ( + + ))} + - ))} - + + {scroll.jump_visible ? ( + + ) : null} {selection && onQuote ? ( 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 ( + + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(utils)/thread-scroll.test.ts b/src/pages/admin/bots/[bot_id]/(utils)/thread-scroll.test.ts new file mode 100644 index 0000000..cc93816 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(utils)/thread-scroll.test.ts @@ -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); + }); +}); diff --git a/src/pages/admin/bots/[bot_id]/(utils)/thread-scroll.ts b/src/pages/admin/bots/[bot_id]/(utils)/thread-scroll.ts new file mode 100644 index 0000000..cfadb46 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(utils)/thread-scroll.ts @@ -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 }); +}