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
@@ -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 });
}