diff --git a/apps/web/src/chat-scroll.test.ts b/apps/web/src/chat-scroll.test.ts new file mode 100644 index 0000000000..4d85278bc1 --- /dev/null +++ b/apps/web/src/chat-scroll.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { + AUTO_SCROLL_BOTTOM_THRESHOLD_PX, + isScrollContainerNearBottom, +} from "./chat-scroll"; + +describe("isScrollContainerNearBottom", () => { + it("returns true when already at bottom", () => { + expect( + isScrollContainerNearBottom({ + scrollTop: 600, + clientHeight: 400, + scrollHeight: 1_000, + }), + ).toBe(true); + }); + + it("returns true when within the auto-scroll threshold", () => { + expect( + isScrollContainerNearBottom({ + scrollTop: 540, + clientHeight: 400, + scrollHeight: 1_000, + }), + ).toBe(true); + }); + + it("returns false when the user is meaningfully above the bottom", () => { + expect( + isScrollContainerNearBottom({ + scrollTop: 520, + clientHeight: 400, + scrollHeight: 1_000, + }), + ).toBe(false); + }); + + it("clamps negative thresholds to zero", () => { + expect( + isScrollContainerNearBottom( + { + scrollTop: 539, + clientHeight: 400, + scrollHeight: 1_000, + }, + -1, + ), + ).toBe(false); + }); + + it("falls back to the default threshold for non-finite values", () => { + expect( + isScrollContainerNearBottom( + { + scrollTop: 540, + clientHeight: 400, + scrollHeight: 1_000, + }, + Number.NaN, + ), + ).toBe(true); + expect(AUTO_SCROLL_BOTTOM_THRESHOLD_PX).toBe(64); + }); +}); diff --git a/apps/web/src/chat-scroll.ts b/apps/web/src/chat-scroll.ts new file mode 100644 index 0000000000..35190ab1b9 --- /dev/null +++ b/apps/web/src/chat-scroll.ts @@ -0,0 +1,24 @@ +export const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; + +interface ScrollPosition { + scrollTop: number; + clientHeight: number; + scrollHeight: number; +} + +export function isScrollContainerNearBottom( + position: ScrollPosition, + thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX, +): boolean { + const threshold = Number.isFinite(thresholdPx) + ? Math.max(0, thresholdPx) + : AUTO_SCROLL_BOTTOM_THRESHOLD_PX; + + const { scrollTop, clientHeight, scrollHeight } = position; + if (![scrollTop, clientHeight, scrollHeight].every(Number.isFinite)) { + return true; + } + + const distanceFromBottom = scrollHeight - clientHeight - scrollTop; + return distanceFromBottom <= threshold; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 806d26fdc4..f9649d2f9f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -45,6 +45,7 @@ import { formatElapsed, formatTimestamp, } from "../session-logic"; +import { isScrollContainerNearBottom } from "../chat-scroll"; import { useStore } from "../store"; import type { ChatImageAttachment } from "../types"; import BranchToolbar from "./BranchToolbar"; @@ -145,6 +146,7 @@ export default function ChatView() { const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const messagesScrollRef = useRef(null); const messagesEndRef = useRef(null); + const shouldAutoScrollRef = useRef(true); const textareaRef = useRef(null); const composerImagesRef = useRef([]); const dragDepthRef = useRef(0); @@ -366,19 +368,30 @@ export default function ChatView() { // Auto-scroll on new messages const messageCount = activeThread?.messages.length ?? 0; const workLogCount = workLogEntries.length; - useLayoutEffect(() => { - if (!activeThread?.id) return; + const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { const scrollContainer = messagesScrollRef.current; if (!scrollContainer) return; - scrollContainer.scrollTop = scrollContainer.scrollHeight; - }, [activeThread?.id]); + scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); + shouldAutoScrollRef.current = true; + }, []); + const onMessagesScroll = useCallback(() => { + const scrollContainer = messagesScrollRef.current; + if (!scrollContainer) return; + shouldAutoScrollRef.current = isScrollContainerNearBottom(scrollContainer); + }, []); + useLayoutEffect(() => { + if (!activeThread?.id) return; + scrollMessagesToBottom(); + }, [activeThread?.id, scrollMessagesToBottom]); useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messageCount]); + if (!shouldAutoScrollRef.current) return; + scrollMessagesToBottom("smooth"); + }, [messageCount, scrollMessagesToBottom]); useEffect(() => { if (phase !== "running") return; - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [phase, workLogCount]); + if (!shouldAutoScrollRef.current) return; + scrollMessagesToBottom("smooth"); + }, [phase, workLogCount, scrollMessagesToBottom]); useEffect(() => { setExpandedWorkGroups({}); @@ -943,7 +956,11 @@ export default function ChatView() { )} {/* Messages */} -
+
{activeThread.messages.length === 0 && !isWorking ? (