Skip to content
Merged
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
65 changes: 65 additions & 0 deletions apps/web/src/chat-scroll.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
24 changes: 24 additions & 0 deletions apps/web/src/chat-scroll.ts
Original file line number Diff line number Diff line change
@@ -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;
}
35 changes: 26 additions & 9 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -145,6 +146,7 @@ export default function ChatView() {
const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
const messagesScrollRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const shouldAutoScrollRef = useRef(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
const dragDepthRef = useRef(0);
Expand Down Expand Up @@ -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({});
Expand Down Expand Up @@ -943,7 +956,11 @@ export default function ChatView() {
)}

{/* Messages */}
<div ref={messagesScrollRef} className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div
ref={messagesScrollRef}
className="min-h-0 flex-1 overflow-y-auto px-5 py-4"
onScroll={onMessagesScroll}
>
{activeThread.messages.length === 0 && !isWorking ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground/30">
Expand Down