From e2697d63ec52e719ebd2d4e25baee2cb58243c83 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:11:16 +0200 Subject: [PATCH] fix(web): keep following the stream after scrolling back to the live edge (#6519) --- apps/web/src/components/ChatView.tsx | 23 +++++++-- .../components/chat/MessagesTimeline.test.tsx | 50 +++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a84433749a15af9f383a4ecc7c51067b8f419655..0a8a71e781fb8dacdda9db9ce6b069e38f7888fb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1205,6 +1205,20 @@ function chatActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } +/** + * Drops the send-time anchored end space. That space is what holds a sent + * message near the top while its turn streams, and it keeps LegendList's + * maintainScrollAtEnd switched off for as long as it is installed — ChatView + * drives the streaming scrolls itself, but only in "anchoring-new-turn" mode. + * So every return to the live edge has to release the anchor too, otherwise the + * timeline settles into "following-end" with nothing following anything. + */ +function releaseChatTimelineAnchor( + current: T, +): T { + return current.messageId === null ? current : { ...current, messageId: null }; +} + function ChatViewContent(props: ChatViewProps) { const { environmentId, @@ -3833,9 +3847,7 @@ function ChatViewContent(props: ChatViewProps) { activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - setTimelineAnchor((current) => - current.messageId === null ? current : { ...current, messageId: null }, - ); + setTimelineAnchor(releaseChatTimelineAnchor); requestAnimationFrame(() => { void legendListRef.current?.scrollToEnd?.({ animated }); }); @@ -4010,6 +4022,11 @@ function ChatViewContent(props: ChatViewProps) { timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; setTimelineLiveFollowEnabled(true); + // Reachable only once manual navigation has already broken follow, so + // the anchored turn framing is over: the user scrolled back to the live + // edge and expects the stream to stick to it again, exactly like the + // scroll-to-bottom pill. + setTimelineAnchor(releaseChatTimelineAnchor); showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); } else { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 1969f00004c575d6dfc8711ebe250c6390073443..ea6cda9b62083641f176f7e6df431479995d377c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -480,6 +480,56 @@ describe("MessagesTimeline", () => { expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1); }); + it("hands end-following back to the list once the send anchor is released", () => { + const firstEntry = buildUserTimelineEntry("First prompt."); + const secondEntry = { + ...buildUserTimelineEntry("Newest prompt."), + id: "entry-2", + message: { + ...buildUserTimelineEntry("Newest prompt.").message, + id: MessageId.make("message-2"), + }, + }; + const timelineEntries = [firstEntry, secondEntry]; + + // While the send anchor holds the end space open, ChatView owns streaming + // scrolls and LegendList must not re-pin behind it. + expect( + renderToStaticMarkup( + , + ), + ).not.toContain('data-maintain-scroll-at-end="enabled"'); + + // Dropping the anchor is what actually gives end-following back, so + // returning to the live edge has to release it — re-enabling live follow + // alone leaves nothing pinned to the stream. + expect( + renderToStaticMarkup( + , + ), + ).toContain('data-maintain-scroll-at-end="enabled"'); + + // Reading history still wins over both. + expect( + renderToStaticMarkup( + , + ), + ).not.toContain('data-maintain-scroll-at-end="enabled"'); + }); + it("renders collapse controls for long user messages", () => { const markup = renderToStaticMarkup(