authorgravatar for 35393046+lnieuwenhuis@users.noreply.github.comLars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> 2026-08-21 07:11:16+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-20 22:11:16-07:00
loge2697d63ec52e719ebd2d4e25baee2cb58243c83
tree612988ca085d29d8188411e8214533982f0489b8
parentce8ca5bb3d005d8653d1b09c1e7c9e02d3ef8ae4
signature Signed by PGP key B5690EEEBB952194

fix(web): keep following the stream after scrolling back to the live edge (#6519)


2 files changed, 70 insertions(+), 3 deletions(-)

apps/web/src/components/ChatView.tsx+20-3
......@@ -1205,6 +1205,20 @@ function chatActionErrorMessage(error: unknown): string {
12051205 return error instanceof Error ? error.message : "An error occurred.";
12061206}
12071207
1208/**
1209 * Drops the send-time anchored end space. That space is what holds a sent
1210 * message near the top while its turn streams, and it keeps LegendList's
1211 * maintainScrollAtEnd switched off for as long as it is installed — ChatView
1212 * drives the streaming scrolls itself, but only in "anchoring-new-turn" mode.
1213 * So every return to the live edge has to release the anchor too, otherwise the
1214 * timeline settles into "following-end" with nothing following anything.
1215 */
1216function releaseChatTimelineAnchor<T extends { readonly messageId: MessageId | null }>(
1217 current: T,
1218): T {
1219 return current.messageId === null ? current : { ...current, messageId: null };
1220}
1221
12081222function ChatViewContent(props: ChatViewProps) {
12091223 const {
12101224 environmentId,
......@@ -3833,9 +3847,7 @@ function ChatViewContent(props: ChatViewProps) {
38333847 activeTimelineAnchorIndexRef.current = null;
38343848 showScrollDebouncer.current.cancel();
38353849 setShowScrollToBottom(false);
3836 setTimelineAnchor((current) =>
3837 current.messageId === null ? current : { ...current, messageId: null },
3838 );
3850 setTimelineAnchor(releaseChatTimelineAnchor);
38393851 requestAnimationFrame(() => {
38403852 void legendListRef.current?.scrollToEnd?.({ animated });
38413853 });
......@@ -4010,6 +4022,11 @@ function ChatViewContent(props: ChatViewProps) {
40104022 timelineScrollModeRef.current = "following-end";
40114023 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
40124024 setTimelineLiveFollowEnabled(true);
4025 // Reachable only once manual navigation has already broken follow, so
4026 // the anchored turn framing is over: the user scrolled back to the live
4027 // edge and expects the stream to stick to it again, exactly like the
4028 // scroll-to-bottom pill.
4029 setTimelineAnchor(releaseChatTimelineAnchor);
40134030 showScrollDebouncer.current.cancel();
40144031 setShowScrollToBottom(false);
40154032 } else {
apps/web/src/components/chat/MessagesTimeline.test.tsx+50
......@@ -480,6 +480,56 @@ describe("MessagesTimeline", () => {
480480 expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1);
481481 });
482482
483 it("hands end-following back to the list once the send anchor is released", () => {
484 const firstEntry = buildUserTimelineEntry("First prompt.");
485 const secondEntry = {
486 ...buildUserTimelineEntry("Newest prompt."),
487 id: "entry-2",
488 message: {
489 ...buildUserTimelineEntry("Newest prompt.").message,
490 id: MessageId.make("message-2"),
491 },
492 };
493 const timelineEntries = [firstEntry, secondEntry];
494
495 // While the send anchor holds the end space open, ChatView owns streaming
496 // scrolls and LegendList must not re-pin behind it.
497 expect(
498 renderToStaticMarkup(
499 <MessagesTimeline
500 {...buildProps()}
501 anchorMessageId={secondEntry.message.id}
502 timelineEntries={timelineEntries}
503 />,
504 ),
505 ).not.toContain('data-maintain-scroll-at-end="enabled"');
506
507 // Dropping the anchor is what actually gives end-following back, so
508 // returning to the live edge has to release it — re-enabling live follow
509 // alone leaves nothing pinned to the stream.
510 expect(
511 renderToStaticMarkup(
512 <MessagesTimeline
513 {...buildProps()}
514 anchorMessageId={null}
515 timelineEntries={timelineEntries}
516 />,
517 ),
518 ).toContain('data-maintain-scroll-at-end="enabled"');
519
520 // Reading history still wins over both.
521 expect(
522 renderToStaticMarkup(
523 <MessagesTimeline
524 {...buildProps()}
525 anchorMessageId={null}
526 liveFollowEnabled={false}
527 timelineEntries={timelineEntries}
528 />,
529 ),
530 ).not.toContain('data-maintain-scroll-at-end="enabled"');
531 });
532
483533 it("renders collapse controls for long user messages", () => {
484534 const markup = renderToStaticMarkup(
485535 <MessagesTimeline