| ... | @@ -1205,6 +1205,20 @@ function chatActionErrorMessage(error: unknown): string { | ... | @@ -1205,6 +1205,20 @@ function chatActionErrorMessage(error: unknown): string { |
| 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 | */ |
| | 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 | function ChatViewContent(props: ChatViewProps) { | 1222 | function ChatViewContent(props: ChatViewProps) { |
| 1209 | const { | 1223 | const { |
| 1210 | environmentId, | 1224 | environmentId, |
| ... | @@ -3833,9 +3847,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3833,9 +3847,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 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) { | ... | @@ -4010,6 +4022,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 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 { |