From 6a00d6a8301e1b45d8087f0412c7e6d5ec8b7021 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 25 Aug 2026 01:54:52 +0300 Subject: [PATCH] fix(chat): drive anchored end-follow from the list's total size Phase 2 of the anchored turn (following the live edge once the reply outgrows the viewport) never ran: the reveal correction was triggered only by entries-array changes, and the streaming tail grows inside a single row without touching the array. The hook now also subscribes to LegendList's totalSize and re-runs the same guarded correction on every content growth. --- .../ui/src/hooks/useChatTimelineScroll.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 038fc1d4..08acd2bb 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -56,7 +56,13 @@ export interface AnimationHandlers { // hook stays testable without a renderer and does not hard-depend on the list // implementation. export interface TimelineListHandle { - getState: () => TimelineListMeasurementState & { readonly scroll: number }; + getState: () => TimelineListMeasurementState & { + readonly scroll: number; + readonly listen?: ( + listenerType: 'totalSize', + callback: (value: number) => void, + ) => () => void; + }; getScrollableNode: () => HTMLElement | null; scrollToEnd: (options?: { animated?: boolean }) => unknown; scrollToOffset: (params: { offset: number; animated?: boolean }) => unknown; @@ -555,6 +561,22 @@ export const useChatTimelineScroll = ({ }); }, [isLiveFollowActive, realContentOverflowsViewport]); + // The streaming tail grows inside one row without changing the entries + // array, so data-change callbacks are silent for the entire stream. The + // list's total content size is the authoritative growth signal; every + // change re-runs the same guarded correction. + const onTimelineDataChangeRef = React.useRef(onTimelineDataChange); + onTimelineDataChangeRef.current = onTimelineDataChange; + React.useEffect(() => { + if (!scrollNode) return; + const listen = listRef.current?.getState().listen; + if (!listen) return; + const unsubscribe = listen('totalSize', () => { + onTimelineDataChangeRef.current(); + }); + return unsubscribe; + }, [scrollNode]); + // ── gesture opt-out ───────────────────────────────────────────────────── const onManualNavigationRef = React.useRef(onManualNavigation); onManualNavigationRef.current = onManualNavigation;