History

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

Lars Nieuwenhuis committed 2 months ago

Commit e2697d61 parent ce8ca5b2 files · +70 −3Browse files

Unified · Split

2 files
  1. apps/web/src/components/ChatView.tsx +20 −3
  2. apps/web/src/components/chat/MessagesTimeline.test.tsx +50 −0
apps/web/src/components/ChatView.tsx+20−3
@@ -1205,6 +1205,20 @@ function chatActionErrorMessage(error: unknown): string { · 1204 unmodified lines
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+ */
1216+function releaseChatTimelineAnchor<T extends { readonly messageId: MessageId | null }>(
1217+ current: T,
1218+): T {
1219+ return current.messageId === null ? current : { ...current, messageId: null };
1220+}
1221+
12081222 function ChatViewContent(props: ChatViewProps) {
12091223 const {
12101224 environmentId,
@@ -3833,9 +3847,7 @@ function ChatViewContent(props: ChatViewProps) { · 2622 unmodified lines
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) { · 168 unmodified lines
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−0
@@ -480,6 +480,56 @@ describe("MessagesTimeline", () => { · 479 unmodified lines
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