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 }; +}