Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions src/client/features/onboarding/OnboardingChatConversation.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<HTMLDivElement>(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 = [
Expand Down Expand Up @@ -172,7 +172,11 @@ export function OnboardingChatConversation({
/>

<div className="flex min-w-0 flex-1 flex-col">
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-6">
<div
ref={scrollRef}
onScroll={onScroll}
className="flex-1 overflow-y-auto px-5 py-6"
>
<div className="mx-auto max-w-2xl space-y-6">
<WelcomeMessage
domain={domain}
Expand Down
14 changes: 9 additions & 5 deletions src/client/features/sam/SamConversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useAgentChat } from "@cloudflare/think/react";
import { useEffect, useRef } from "react";
import { ChatComposer } from "@/client/features/onboarding/OnboardingChatParts";
import { invalidateSamSessions } from "@/client/features/sam/samQueries";
import { useStickToBottom } from "@/client/hooks/useStickToBottom";
import {
ChatMessage,
humanizeToolLabel,
Expand Down Expand Up @@ -74,11 +75,10 @@ export function SamConversation({
}, [isBusy, projectId]);

// Pin to the bottom while the user follows along.
const scrollRef = useRef<HTMLDivElement>(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 =
Expand All @@ -101,7 +101,11 @@ export function SamConversation({
Clear history (dev)
</button>
) : null}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-6">
<div
ref={scrollRef}
onScroll={onScroll}
className="flex-1 overflow-y-auto px-5 py-6"
>
<div className="mx-auto max-w-2xl space-y-6">
{messages.length === 0 ? (
<div className="space-y-2 text-sm text-base-content/80">
Expand Down
40 changes: 40 additions & 0 deletions src/client/hooks/useStickToBottom.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
47 changes: 47 additions & 0 deletions src/client/hooks/useStickToBottom.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 };
}
Loading