From 0c40b9a1ab3fc1d9b2d5a2f0839a770cdd94a490 Mon Sep 17 00:00:00 2001 From: Abhay Padmanabhan <58100924+abhaypadmanabhan@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:17:29 -0700 Subject: [PATCH 1/2] Chat: a reader at the latest message stays there when the column changes size Opening the side panel narrows the chat, every bubble grows taller and the scroll position stayed, landing the reader in old messages. A reader at the bottom now stays at the bottom when the chat or its content changes size; one who scrolled up keeps their place. Co-Authored-By: Claude Opus 5.5 --- components/app/chat-view.tsx | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/components/app/chat-view.tsx b/components/app/chat-view.tsx index afe3eec69..d797ee856 100644 --- a/components/app/chat-view.tsx +++ b/components/app/chat-view.tsx @@ -443,12 +443,39 @@ 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), and when the chat last scrolled itself. + const follow = useRef(true); + const ownScroll = useRef(0); useLayoutEffect(() => { const el = scroller.current; if (!el) return; + follow.current = true; + ownScroll.current = Date.now(); 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. + useEffect(() => { + const el = scroller.current; + if (!el || typeof ResizeObserver !== "function") return; + const onScroll = () => { + if (Date.now() - ownScroll.current < 700) return; + follow.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + }; + const ro = new ResizeObserver(() => { + if (!follow.current) return; + ownScroll.current = Date.now(); + 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; From 9baf785483d8731120162951d02eff98284b79fa Mon Sep 17 00:00:00 2001 From: Abhay Padmanabhan <58100924+abhaypadmanabhan@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:22:13 -0700 Subject: [PATCH 2/2] Chat: a reader who scrolls up right after the chat moved itself keeps their place Review: a scroll within 700 ms of the chat's own scroll was ignored, so a reader who scrolled up then stayed followed and the next size change pulled them back down. The chat only ever scrolls itself down, so moving up away from the bottom now always stops following, and reaching the bottom starts it; no timer. Co-Authored-By: Claude Opus 5.5 --- components/app/chat-view.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/components/app/chat-view.tsx b/components/app/chat-view.tsx index d797ee856..d190a3a88 100644 --- a/components/app/chat-view.tsx +++ b/components/app/chat-view.tsx @@ -443,30 +443,29 @@ 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), and when the chat last scrolled itself. + // Whether the reader is at the latest message, so the chat follows it. const follow = useRef(true); - const ownScroll = useRef(0); useLayoutEffect(() => { const el = scroller.current; if (!el) return; follow.current = true; - ownScroll.current = Date.now(); 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. + // 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 (Date.now() - ownScroll.current < 700) return; - follow.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + 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) return; - ownScroll.current = Date.now(); - el.scrollTop = el.scrollHeight; + if (follow.current) el.scrollTop = el.scrollHeight; }); ro.observe(el); for (const child of Array.from(el.children)) ro.observe(child);