| @@ -1205,6 +1205,20 @@ function chatActionErrorMessage(error: unknown): string { · 1204 unmodified lines |
| 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) { · 2622 unmodified lines |
| 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) { · 168 unmodified lines |
| 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 { |