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
1205 return error instanceof Error ? error.message : "An error occurred.";1205 return error instanceof Error ? error.message : "An error occurred.";
1206}1206}
1207 1207
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
1208function ChatViewContent(props: ChatViewProps) {1222function ChatViewContent(props: ChatViewProps) {
1209 const {1223 const {
1210 environmentId,1224 environmentId,
@@ -3833,9 +3847,7 @@ function ChatViewContent(props: ChatViewProps) { · 2622 unmodified lines
3833 activeTimelineAnchorIndexRef.current = null;3847 activeTimelineAnchorIndexRef.current = null;
3834 showScrollDebouncer.current.cancel();3848 showScrollDebouncer.current.cancel();
3835 setShowScrollToBottom(false);3849 setShowScrollToBottom(false);
3836 setTimelineAnchor((current) =>3850 setTimelineAnchor(releaseChatTimelineAnchor);
3837 current.messageId === null ? current : { ...current, messageId: null },
3838 );
3839 requestAnimationFrame(() => {3851 requestAnimationFrame(() => {
3840 void legendListRef.current?.scrollToEnd?.({ animated });3852 void legendListRef.current?.scrollToEnd?.({ animated });
3841 });3853 });
@@ -4010,6 +4022,11 @@ function ChatViewContent(props: ChatViewProps) { · 168 unmodified lines
4010 timelineScrollModeRef.current = "following-end";4022 timelineScrollModeRef.current = "following-end";
4011 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;4023 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
4012 setTimelineLiveFollowEnabled(true);4024 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);
4013 showScrollDebouncer.current.cancel();4030 showScrollDebouncer.current.cancel();
4014 setShowScrollToBottom(false);4031 setShowScrollToBottom(false);
4015 } else {4032 } else {
apps/web/src/components/chat/MessagesTimeline.test.tsx+50−0
@@ -480,6 +480,56 @@ describe("MessagesTimeline", () => { · 479 unmodified lines
480 expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1);480 expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1);
481 });481 });
482 482
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
483 it("renders collapse controls for long user messages", () => {533 it("renders collapse controls for long user messages", () => {
484 const markup = renderToStaticMarkup(534 const markup = renderToStaticMarkup(
485 <MessagesTimeline535 <MessagesTimeline