From 04f338cc49d064e6c00524eb0395c29bc3816d9c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 25 Aug 2026 16:49:22 +0300 Subject: [PATCH] feat(chat): upgrade @legendapp/list to 3.3.8 and let it own end following MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3.3.x makes maintainScrollAtEnd follow content growth on its own — a tail row growing in place included — which is exactly what the manual totalSize correction existed for. Delete that correction (the totalSize listener now only drives the anchored-turn glide) and pick up 3.3.x's measurement batching, prepend-flash fixes, and web programmatic-scroll fixes. Opt the explicit maintainScrollAtEnd config into footerLayout per the 3.1.1 guidance. The library's own released-on-user-scroll heuristic proved unreliable one run in three against synthetic touch, so the gesture state machine stays authoritative: while a real gesture owns the scroll, the list's end pinning is switched off through a threaded endPinningReleased prop and re-engages when the user returns to the end. Validated with the CDP battery on a production build: stream follow stays at distance 0, mobile drag releases with the pill shown in three of three runs, resize oscillation stays at the reduced level, the rail reaches the last turn, and profiled streaming cost per rendered character matches the tuned 3.2.0 numbers. --- bun.lock | 4 +-- packages/ui/package.json | 2 +- .../ui/src/components/chat/ChatContainer.tsx | 5 +++ .../ui/src/components/chat/MessageList.tsx | 11 +++++-- .../ui/src/hooks/useChatTimelineScroll.ts | 32 ++++--------------- 5 files changed, 24 insertions(+), 30 deletions(-) diff --git a/bun.lock b/bun.lock index d9713441..a868cdf8 100644 --- a/bun.lock +++ b/bun.lock @@ -167,7 +167,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@legendapp/list": "3.2.0", + "@legendapp/list": "3.3.8", "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "1.18.21", "@pierre/diffs": "1.3.0-beta.6", @@ -918,7 +918,7 @@ "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], - "@legendapp/list": ["@legendapp/list@3.2.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-bN+g/oQYjFz+UAyuBN4cmYJAwdJS1TdNcZZOVlh3+VwCQUWrsg0PH46Mvm76gdZSCYMfoFanPY4dKnILcYEzeg=="], + "@legendapp/list": ["@legendapp/list@3.3.8", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-GM4Hca/6WDvcY33XXCieR9MaG9CoZmACzwqQwRhKFSaNKfQV1lTLiTOpWuA9wnE8n8+6WeA52DwNKC9yrnPPeg=="], "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index dc5e40f1..3de6de14 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -43,7 +43,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@legendapp/list": "3.2.0", + "@legendapp/list": "3.3.8", "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "1.18.21", "@pierre/diffs": "1.3.0-beta.6", diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index a49909a2..66b40858 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -173,6 +173,7 @@ type ChatViewportProps = { fallbackTimestamp?: number; } | null; scrollToBottom: () => void; + endPinningReleased: boolean; sessionQuestions: QuestionRequest[]; sessionPermissions: PermissionRequest[]; isProgrammaticFollowActive: boolean; @@ -209,6 +210,7 @@ const ChatViewport = React.memo(({ activeStreamingPhase, retryOverlay, scrollToBottom, + endPinningReleased, sessionQuestions, sessionPermissions, isProgrammaticFollowActive, @@ -392,6 +394,7 @@ const ChatViewport = React.memo(({ retryOverlay={retryOverlay} isLoadingOlder={isLoadingOlder} scrollToBottom={scrollToBottom} + endPinningReleased={endPinningReleased} directory={directory} registerList={registerList} anchorMessageId={anchorMessageId} @@ -438,6 +441,7 @@ const ChatViewport = React.memo(({ && prev.activeStreamingPhase === next.activeStreamingPhase && prev.retryOverlay === next.retryOverlay && prev.scrollToBottom === next.scrollToBottom + && prev.endPinningReleased === next.endPinningReleased && prev.sessionQuestions === next.sessionQuestions && prev.sessionPermissions === next.sessionPermissions && prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive @@ -1353,6 +1357,7 @@ export const ChatContainer: React.FC = ({ activeStreamingPhase={activeStreamingPhase} retryOverlay={retryOverlay} scrollToBottom={resumeToLatestInstant} + endPinningReleased={userOwnsScroll} sessionQuestions={sessionQuestions} sessionPermissions={sessionPermissions} isProgrammaticFollowActive={isFollowingProgrammatically} diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 04d73563..b756d439 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -322,6 +322,9 @@ interface MessageListProps { // The list owns its scroll container; the timeline scroll hook drives it // through this ref and observes it through the callbacks below. registerList?: (list: LegendListRef | null) => void; + // True while a real gesture owns the scroll; releases the list's own + // end pinning so the state machine, not the library heuristic, decides. + endPinningReleased?: boolean; // The anchored row is identified by message id; the index it maps to is a // property of the row model, which only this component knows. anchorMessageId?: string | null; @@ -948,6 +951,7 @@ type TimelineListProps = { entries: RenderEntry[]; streamingTailKey: string | null; registerList: (list: LegendListRef | null) => void; + endPinningReleased: boolean; anchoredEndSpace?: { anchorIndex: number; anchorOffset?: number; @@ -966,6 +970,7 @@ type TimelineListProps = { const TimelineList = React.memo(({ entries, registerList, + endPinningReleased, anchoredEndSpace, composerOverlayHeight, onIsAtEndChange, @@ -1065,9 +1070,9 @@ const TimelineList = React.memo(({ // rows that are still re-measuring shakes the pinned // viewport; the owning hook re-asserts the end once the // resize settles. - maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing + maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing || endPinningReleased ? false - : { animated: false, on: { dataChange: true, itemLayout: true, layout: true } }} + : { animated: false, on: { dataChange: true, itemLayout: true, layout: true, footerLayout: true } }} // Prepending older history must not move what the user is // reading. Size restoration applies only during a width // resize — see the observer above. @@ -1164,6 +1169,7 @@ const MessageList = React.forwardRef(({ scrollToBottom, directory, registerList, + endPinningReleased = false, anchorMessageId = null, onAnchorReady, onAnchorSizeChanged, @@ -1791,6 +1797,7 @@ const MessageList = React.forwardRef(({ listFooter={listFooter} scrollContainerProps={scrollContainerProps} rowContext={rowContext} + endPinningReleased={endPinningReleased} /> ); diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 29962ce6..5e4dd1d8 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -548,27 +548,12 @@ export const useChatTimelineScroll = ({ if (!streamingAutoFollowEnabledRef.current) return; if (!isLiveFollowActive()) return; - // Following the end needs no animation frames: the totalSize listener - // already fires after measurement, so correct synchronously the way - // the previous scroll engine wrote scrollTop directly. Scheduling a - // two-frame chain per streamed chunk kept a continuous rAF load (and - // its per-frame style recalcs) running for the whole stream. - if (modeRef.current === 'following-end') { - const list = listRef.current; - if (!list) return; - if (!realContentOverflowsViewport(list)) return; - // Write scrollTop directly instead of going through scrollToEnd: - // the list's programmatic-scroll machinery schedules follow-up - // animation frames per call, which doubles frame production for - // the whole stream. A direct write is what a user gesture does, - // and the list reconciles it through its normal onScroll path. - const node = list.getScrollableNode(); - if (!node) return; - // Overshoot so the browser clamps to the exact fractional maximum - // (scrollHeight is integer-rounded). - node.scrollTop = node.scrollHeight + 4096; - return; - } + // Since @legendapp/list 3.3.x, maintainScrollAtEnd follows content + // growth on its own — including a tail row growing in place — and + // releases when the user scrolls away. Following the end therefore + // needs no correction here; this handler only serves the + // anchored-turn glide below. + if (modeRef.current === 'following-end') return; const frames = dataChangeFramesRef.current; if (frames.first !== null) cancelAnimationFrame(frames.first); @@ -615,12 +600,9 @@ export const useChatTimelineScroll = ({ return; } - if (modeRef.current !== 'following-end') return; - if (!realContentOverflowsViewport(list)) return; - void list.scrollToEnd({ animated: false }); }); }); - }, [isLiveFollowActive, realContentOverflowsViewport]); + }, [isLiveFollowActive]); // The streaming tail grows inside one row without changing the entries // array, so data-change callbacks are silent for the entire stream. The