diff --git a/components/app/chat-view.tsx b/components/app/chat-view.tsx index 9c585a53c..8dc32b463 100644 --- a/components/app/chat-view.tsx +++ b/components/app/chat-view.tsx @@ -445,12 +445,38 @@ export function ChatView({ // Opening a chat lands on its latest message; only new messages in the open chat glide in. const shownChat = useRef(null); + // Whether the reader is at the latest message, so the chat follows it. + const follow = useRef(true); useLayoutEffect(() => { const el = scroller.current; if (!el) return; + follow.current = true; el.scrollTo({ top: el.scrollHeight, behavior: shownChat.current === c.id && !matchMedia("(prefers-reduced-motion: reduce)").matches ? "smooth" : "instant" }); shownChat.current = c.id; }, [messages.length, c.typing.length, c.id]); + // A reader at the bottom stays there when the column changes size (the side panel opening makes every bubble + // taller): they never land on old messages. One who scrolled up keeps their place. The chat only ever scrolls + // itself down, so moving up away from the bottom is always the reader's own. + useEffect(() => { + const el = scroller.current; + if (!el || typeof ResizeObserver !== "function") return; + let top = el.scrollTop; + const onScroll = () => { + if (el.scrollHeight - el.scrollTop - el.clientHeight < 40) follow.current = true; + else if (el.scrollTop < top) follow.current = false; + top = el.scrollTop; + }; + const ro = new ResizeObserver(() => { + if (follow.current) el.scrollTop = el.scrollHeight; + }); + ro.observe(el); + for (const child of Array.from(el.children)) ro.observe(child); + el.addEventListener("scroll", onScroll, { passive: true }); + return () => { + ro.disconnect(); + el.removeEventListener("scroll", onScroll); + }; + }, [c.id]); // Marked read only when there's something unread: each mark is a state change every open page reloads. const lastAt = messages.at(-1)?.at ?? 0;