Skip to content

Commit 268e949

Browse files
committed
fix(chat): reduce rendering jank during streaming
The chat view stuttered during streaming responses due to several compounding issues: transition-all on the message container animated height changes as messages grew, smooth scroll was interrupted on every streaming chunk before the previous animation completed, the scrollend listener used anonymous functions so cleanup never matched and broke user scroll-away detection, and the URL regex /g flag made it stateful across split/test calls causing link flicker. Sending a message now always scrolls to bottom and re-enables auto-scroll so the agent response stays visible.
1 parent b434b09 commit 268e949

File tree

1 file changed

+17
-48
lines changed

1 file changed

+17
-48
lines changed

chat/src/components/message-list.tsx

Lines changed: 17 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,6 @@ export default function MessageList({messages}: MessageListProps) {
2828

2929
// Track if user is at bottom - default to true for initial scroll
3030
const isAtBottomRef = useRef(true);
31-
// Track the last known scroll height to detect new content
32-
const lastScrollHeightRef = useRef(0);
33-
// Track if we're currently doing a programmatic scroll
34-
const isProgrammaticScrollRef = useRef(false);
3531

3632
const checkIfAtBottom = useCallback(() => {
3733
if (!scrollAreaRef) return false;
@@ -60,58 +56,31 @@ export default function MessageList({messages}: MessageListProps) {
6056
};
6157
}, []);
6258

63-
// Update isAtBottom on scroll
59+
// Track whether the user is scrolled to the bottom. Every scroll event
60+
// updates the ref so auto-scroll decisions are always based on the
61+
// user's actual position.
6462
useEffect(() => {
6563
if (!scrollAreaRef) return;
66-
6764
const handleScroll = () => {
68-
if (isProgrammaticScrollRef.current) return;
6965
isAtBottomRef.current = checkIfAtBottom();
7066
};
71-
72-
// Initial check
7367
handleScroll();
74-
7568
scrollAreaRef.addEventListener("scroll", handleScroll);
76-
scrollAreaRef.addEventListener("scrollend", () => isProgrammaticScrollRef.current = false);
77-
return () => {
78-
scrollAreaRef.removeEventListener("scroll", handleScroll)
79-
scrollAreaRef.removeEventListener("scrollend", () => isProgrammaticScrollRef.current = false);
80-
81-
};
69+
return () => scrollAreaRef.removeEventListener("scroll", handleScroll);
8270
}, [checkIfAtBottom, scrollAreaRef]);
8371

84-
// Handle auto-scrolling when messages change
72+
// Pin to bottom when new content arrives, but only if the user hasn't
73+
// scrolled away. Always scroll when the latest message is from the user
74+
// (they just sent it and should see it). Direct scrollTop assignment is
75+
// synchronous and avoids the animation conflicts that smooth scrollTo
76+
// causes during streaming.
8577
useLayoutEffect(() => {
8678
if (!scrollAreaRef) return;
87-
88-
const currentScrollHeight = scrollAreaRef.scrollHeight;
89-
90-
// Check if this is new content (scroll height increased)
91-
const hasNewContent = currentScrollHeight > lastScrollHeightRef.current;
92-
const isFirstRender = lastScrollHeightRef.current === 0;
93-
const isNewUserMessage =
94-
messages.length > 0 && messages[messages.length - 1].role === "user";
95-
96-
// Auto-scroll only if:
97-
// 1. It's the first render, OR
98-
// 2. There's new content AND user was at the bottom, OR
99-
// 3. The user sent a new message
100-
if (
101-
hasNewContent &&
102-
(isFirstRender || isAtBottomRef.current || isNewUserMessage)
103-
) {
104-
isProgrammaticScrollRef.current = true;
105-
scrollAreaRef.scrollTo({
106-
top: currentScrollHeight,
107-
behavior: isFirstRender ? "instant" : "smooth",
108-
});
109-
// After scrolling, we're at the bottom
110-
isAtBottomRef.current = true;
111-
}
112-
113-
// Update the last known scroll height
114-
lastScrollHeightRef.current = currentScrollHeight;
79+
const lastMessage = messages[messages.length - 1];
80+
const isUserMessage = lastMessage && lastMessage.role === "user";
81+
if (!isAtBottomRef.current && !isUserMessage) return;
82+
scrollAreaRef.scrollTop = scrollAreaRef.scrollHeight;
83+
isAtBottomRef.current = true;
11584
}, [messages, scrollAreaRef]);
11685

11786
// If no messages, show a placeholder
@@ -126,7 +95,7 @@ export default function MessageList({messages}: MessageListProps) {
12695
return (
12796
<div className="overflow-y-auto flex-1" ref={setScrollAreaRef}>
12897
<div
129-
className="p-4 flex flex-col gap-4 max-w-4xl mx-auto transition-all duration-300 ease-in-out min-h-0">
98+
className="p-4 flex flex-col gap-4 max-w-4xl mx-auto min-h-0">
13099
{messages.map((message, index) => (
131100
<div
132101
key={message.id ?? "draft"}
@@ -137,7 +106,7 @@ export default function MessageList({messages}: MessageListProps) {
137106
message.role === "user"
138107
? "bg-accent-foreground rounded-lg max-w-[90%] px-4 py-3 text-accent"
139108
: "max-w-[80ch]"
140-
} ${message.id === undefined ? "animate-pulse" : ""}`}
109+
}`}
141110
>
142111
<div
143112
className={`whitespace-pre-wrap break-words text-left text-xs md:text-sm leading-relaxed md:leading-normal ${
@@ -186,7 +155,7 @@ const ProcessedMessage = React.memo(function ProcessedMessage({
186155
}: ProcessedMessageProps) {
187156
// Regex to find URLs
188157
// https://stackoverflow.com/a/17773849
189-
const urlRegex = useMemo<RegExp>(() => /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/g, []);
158+
const urlRegex = useMemo<RegExp>(() => /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/, []);
190159

191160
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, url: string) => {
192161
if (e.metaKey || e.ctrlKey) {

0 commit comments

Comments
 (0)