From c499a501add0ed2723124de4d1d6e428275a693b Mon Sep 17 00:00:00 2001 From: Tobi | Nordalux Date: Fri, 31 Jul 2026 15:52:13 +0200 Subject: [PATCH] fix(chat): stop yanking the transcript back down while the reader scrolls up Both chat views pinned the scroll container to the bottom on every messages change. During a streaming reply that fires on each token, so scrolling up to re-read something is undone within milliseconds and the transcript is effectively unreadable until the answer finishes. Pin only while the reader is still at the bottom. Scrolling away releases it, scrolling back re-arms it. The shared hook replaces the duplicated effect in SAM and onboarding; the pin decision is a pure function so it can be tested without a DOM. --- .../onboarding/OnboardingChatConversation.tsx | 16 ++++--- src/client/features/sam/SamConversation.tsx | 14 ++++-- src/client/hooks/useStickToBottom.test.ts | 40 ++++++++++++++++ src/client/hooks/useStickToBottom.ts | 47 +++++++++++++++++++ 4 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 src/client/hooks/useStickToBottom.test.ts create mode 100644 src/client/hooks/useStickToBottom.ts diff --git a/src/client/features/onboarding/OnboardingChatConversation.tsx b/src/client/features/onboarding/OnboardingChatConversation.tsx index f20abb8f..52f05564 100644 --- a/src/client/features/onboarding/OnboardingChatConversation.tsx +++ b/src/client/features/onboarding/OnboardingChatConversation.tsx @@ -1,13 +1,14 @@ import { useAgent } from "agents/react"; import { useAgentChat } from "@cloudflare/ai-chat/react"; import { useCustomer } from "autumn-js/react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { ChatMessage, messageHasVisibleContent, type ResolveToolLabel, } from "@/client/components/chat/ChatMessage"; import { captureClientEvent } from "@/client/lib/posthog"; +import { useStickToBottom } from "@/client/hooks/useStickToBottom"; import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing"; import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat"; import { @@ -131,11 +132,10 @@ export function OnboardingChatConversation({ // Pin to the bottom while the user is following along; the strategy doc plus // a streaming reply quickly grows past the viewport. - const scrollRef = useRef(null); + const { scrollRef, onScroll, stickToBottom } = useStickToBottom(); useEffect(() => { - const el = scrollRef.current; - if (el) el.scrollTop = el.scrollHeight; - }, [messages, status]); + stickToBottom(); + }, [messages, status, stickToBottom]); const lastMessage = messages[messages.length - 1]; const suggestionPool = [ @@ -172,7 +172,11 @@ export function OnboardingChatConversation({ />
-
+
(null); + const { scrollRef, onScroll, stickToBottom } = useStickToBottom(); useEffect(() => { - const el = scrollRef.current; - if (el) el.scrollTop = el.scrollHeight; - }, [messages, status]); + stickToBottom(); + }, [messages, status, stickToBottom]); const lastMessage = messages[messages.length - 1]; const showTyping = @@ -101,7 +101,11 @@ export function SamConversation({ Clear history (dev) ) : null} -
+
{messages.length === 0 ? (
diff --git a/src/client/hooks/useStickToBottom.test.ts b/src/client/hooks/useStickToBottom.test.ts new file mode 100644 index 00000000..4647c637 --- /dev/null +++ b/src/client/hooks/useStickToBottom.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { isFollowingBottom } from "@/client/hooks/useStickToBottom"; + +const viewport = (scrollTop: number) => ({ + scrollHeight: 1000, + clientHeight: 400, + scrollTop, +}); + +describe("isFollowingBottom", () => { + it("follows when pinned to the bottom", () => { + expect(isFollowingBottom(viewport(600))).toBe(true); + }); + + it("still follows within the threshold", () => { + // A streaming reply can grow a few pixels between the scroll event and the + // next render, so being just short of the bottom still counts. + expect(isFollowingBottom(viewport(560))).toBe(true); + }); + + it("stops following once the reader scrolls away", () => { + expect(isFollowingBottom(viewport(200))).toBe(false); + }); + + it("stops following one pixel past the threshold", () => { + // Bottom sits at scrollTop 600, so the default 48px threshold ends at 552. + expect(isFollowingBottom(viewport(552))).toBe(true); + expect(isFollowingBottom(viewport(551))).toBe(false); + }); + + it("honours a custom threshold", () => { + expect(isFollowingBottom(viewport(551), 100)).toBe(true); + expect(isFollowingBottom(viewport(551), 10)).toBe(false); + }); + + it("treats overscroll as following", () => { + // Elastic scrolling can push scrollTop past the resting bottom. + expect(isFollowingBottom(viewport(620))).toBe(true); + }); +}); diff --git a/src/client/hooks/useStickToBottom.ts b/src/client/hooks/useStickToBottom.ts new file mode 100644 index 00000000..f7a588b6 --- /dev/null +++ b/src/client/hooks/useStickToBottom.ts @@ -0,0 +1,47 @@ +import { useCallback, useRef } from "react"; + +/** + * How far from the bottom still counts as "following along". Wide enough to + * survive fractional scroll positions and the few pixels a growing reply adds + * between a scroll event and the next render. + */ +const FOLLOW_THRESHOLD_PX = 48; + +type ScrollMetrics = Pick< + HTMLElement, + "scrollHeight" | "scrollTop" | "clientHeight" +>; + +/** Whether the viewport is close enough to the bottom to keep pinning it. */ +export function isFollowingBottom( + { scrollHeight, scrollTop, clientHeight }: ScrollMetrics, + threshold: number = FOLLOW_THRESHOLD_PX, +): boolean { + return scrollHeight - scrollTop - clientHeight <= threshold; +} + +/** + * Keeps a scroll container pinned to the bottom while new content arrives, but + * only for as long as the reader stays there. Scrolling up during a streaming + * reply releases the pin; scrolling back down re-arms it. + * + * Wire `scrollRef` and `onScroll` to the container, then call `stickToBottom` + * from an effect keyed on whatever grows the content. The caller keeps that + * dependency list so it stays statically checkable. + */ +export function useStickToBottom() { + const scrollRef = useRef(null); + const isFollowingRef = useRef(true); + + const onScroll = useCallback(() => { + const el = scrollRef.current; + if (el) isFollowingRef.current = isFollowingBottom(el); + }, []); + + const stickToBottom = useCallback(() => { + const el = scrollRef.current; + if (el && isFollowingRef.current) el.scrollTop = el.scrollHeight; + }, []); + + return { scrollRef, onScroll, stickToBottom }; +}