authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 10:52:27-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 12:38:55-08:00
log7fc53dadc7d66483cdeac70f233059490785c9e2
tree0c97af9a9d9055945e2ca4b371365185e1d4f196
parent98c4b7c724da7f6aeca67a8c34f53af666fa8a01

Respect chat scroll position during streaming updates

- only auto-scroll when the user is already near the bottom - centralize near-bottom detection in `chat-scroll.ts` with a 64px threshold - add unit tests covering threshold and edge-case behavior Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 115 insertions(+), 9 deletions(-)

apps/web/src/chat-scroll.test.ts created+65
......@@ -0,0 +1,65 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
5 isScrollContainerNearBottom,
6} from "./chat-scroll";
7
8describe("isScrollContainerNearBottom", () => {
9 it("returns true when already at bottom", () => {
10 expect(
11 isScrollContainerNearBottom({
12 scrollTop: 600,
13 clientHeight: 400,
14 scrollHeight: 1_000,
15 }),
16 ).toBe(true);
17 });
18
19 it("returns true when within the auto-scroll threshold", () => {
20 expect(
21 isScrollContainerNearBottom({
22 scrollTop: 540,
23 clientHeight: 400,
24 scrollHeight: 1_000,
25 }),
26 ).toBe(true);
27 });
28
29 it("returns false when the user is meaningfully above the bottom", () => {
30 expect(
31 isScrollContainerNearBottom({
32 scrollTop: 520,
33 clientHeight: 400,
34 scrollHeight: 1_000,
35 }),
36 ).toBe(false);
37 });
38
39 it("clamps negative thresholds to zero", () => {
40 expect(
41 isScrollContainerNearBottom(
42 {
43 scrollTop: 539,
44 clientHeight: 400,
45 scrollHeight: 1_000,
46 },
47 -1,
48 ),
49 ).toBe(false);
50 });
51
52 it("falls back to the default threshold for non-finite values", () => {
53 expect(
54 isScrollContainerNearBottom(
55 {
56 scrollTop: 540,
57 clientHeight: 400,
58 scrollHeight: 1_000,
59 },
60 Number.NaN,
61 ),
62 ).toBe(true);
63 expect(AUTO_SCROLL_BOTTOM_THRESHOLD_PX).toBe(64);
64 });
65});
apps/web/src/chat-scroll.ts created+24
......@@ -0,0 +1,24 @@
1export const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
2
3interface ScrollPosition {
4 scrollTop: number;
5 clientHeight: number;
6 scrollHeight: number;
7}
8
9export function isScrollContainerNearBottom(
10 position: ScrollPosition,
11 thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
12): boolean {
13 const threshold = Number.isFinite(thresholdPx)
14 ? Math.max(0, thresholdPx)
15 : AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
16
17 const { scrollTop, clientHeight, scrollHeight } = position;
18 if (![scrollTop, clientHeight, scrollHeight].every(Number.isFinite)) {
19 return true;
20 }
21
22 const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
23 return distanceFromBottom <= threshold;
24}
apps/web/src/components/ChatView.tsx+26-9
......@@ -45,6 +45,7 @@ import {
4545 formatElapsed,
4646 formatTimestamp,
4747} from "../session-logic";
48import { isScrollContainerNearBottom } from "../chat-scroll";
4849import { useStore } from "../store";
4950import type { ChatImageAttachment } from "../types";
5051import BranchToolbar from "./BranchToolbar";
......@@ -145,6 +146,7 @@ export default function ChatView() {
145146 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
146147 const messagesScrollRef = useRef<HTMLDivElement>(null);
147148 const messagesEndRef = useRef<HTMLDivElement>(null);
149 const shouldAutoScrollRef = useRef(true);
148150 const textareaRef = useRef<HTMLTextAreaElement>(null);
149151 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
150152 const dragDepthRef = useRef(0);
......@@ -366,19 +368,30 @@ export default function ChatView() {
366368 // Auto-scroll on new messages
367369 const messageCount = activeThread?.messages.length ?? 0;
368370 const workLogCount = workLogEntries.length;
369 useLayoutEffect(() => {
370 if (!activeThread?.id) return;
371 const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
371372 const scrollContainer = messagesScrollRef.current;
372373 if (!scrollContainer) return;
373 scrollContainer.scrollTop = scrollContainer.scrollHeight;
374 }, [activeThread?.id]);
374 scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
375 shouldAutoScrollRef.current = true;
376 }, []);
377 const onMessagesScroll = useCallback(() => {
378 const scrollContainer = messagesScrollRef.current;
379 if (!scrollContainer) return;
380 shouldAutoScrollRef.current = isScrollContainerNearBottom(scrollContainer);
381 }, []);
382 useLayoutEffect(() => {
383 if (!activeThread?.id) return;
384 scrollMessagesToBottom();
385 }, [activeThread?.id, scrollMessagesToBottom]);
375386 useEffect(() => {
376 messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
377 }, [messageCount]);
387 if (!shouldAutoScrollRef.current) return;
388 scrollMessagesToBottom("smooth");
389 }, [messageCount, scrollMessagesToBottom]);
378390 useEffect(() => {
379391 if (phase !== "running") return;
380 messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
381 }, [phase, workLogCount]);
392 if (!shouldAutoScrollRef.current) return;
393 scrollMessagesToBottom("smooth");
394 }, [phase, workLogCount, scrollMessagesToBottom]);
382395
383396 useEffect(() => {
384397 setExpandedWorkGroups({});
......@@ -943,7 +956,11 @@ export default function ChatView() {
943956 )}
944957
945958 {/* Messages */}
946 <div ref={messagesScrollRef} className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
959 <div
960 ref={messagesScrollRef}
961 className="min-h-0 flex-1 overflow-y-auto px-5 py-4"
962 onScroll={onMessagesScroll}
963 >
947964 {activeThread.messages.length === 0 && !isWorking ? (
948965 <div className="flex h-full items-center justify-center">
949966 <p className="text-sm text-muted-foreground/30">