| author | |
| committer | |
| log | e2697d63ec52e719ebd2d4e25baee2cb58243c83 |
| tree | 612988ca085d29d8188411e8214533982f0489b8 |
| parent | ce8ca5bb3d005d8653d1b09c1e7c9e02d3ef8ae4 |
| signature | Signed by PGP key B5690EEEBB952194 |
2 files changed, 70 insertions(+), 3 deletions(-)
apps/web/src/components/ChatView.tsx+20-3| ... | ... | @@ -1205,6 +1205,20 @@ function chatActionErrorMessage(error: unknown): string { |
| 1205 | 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 | */ | |
| 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 | ||
| 1208 | 1222 | function ChatViewContent(props: ChatViewProps) { |
| 1209 | 1223 | const { |
| 1210 | 1224 | environmentId, |
| ... | ... | @@ -3833,9 +3847,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3833 | 3847 | activeTimelineAnchorIndexRef.current = null; |
| 3834 | 3848 | showScrollDebouncer.current.cancel(); |
| 3835 | 3849 | setShowScrollToBottom(false); |
| 3836 | setTimelineAnchor((current) => | |
| 3837 | current.messageId === null ? current : { ...current, messageId: null }, | |
| 3838 | ); | |
| 3850 | setTimelineAnchor(releaseChatTimelineAnchor); | |
| 3839 | 3851 | requestAnimationFrame(() => { |
| 3840 | 3852 | void legendListRef.current?.scrollToEnd?.({ animated }); |
| 3841 | 3853 | }); |
| ... | ... | @@ -4010,6 +4022,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 4010 | 4022 | timelineScrollModeRef.current = "following-end"; |
| 4011 | 4023 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; |
| 4012 | 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 | 4030 | showScrollDebouncer.current.cancel(); |
| 4014 | 4031 | setShowScrollToBottom(false); |
| 4015 | 4032 | } else { |
apps/web/src/components/chat/MessagesTimeline.test.tsx+50| ... | ... | @@ -480,6 +480,56 @@ describe("MessagesTimeline", () => { |
| 480 | 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 | 533 | it("renders collapse controls for long user messages", () => { |
| 484 | 534 | const markup = renderToStaticMarkup( |
| 485 | 535 | <MessagesTimeline |