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