From d2d8669564f12be713ca8d02fd3debd781ebd121 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 4 Aug 2026 12:09:37 +0300 Subject: [PATCH] refactor(chat): replace timeline scroll engine with anchored-turn LegendList Sending a message now parks that message near the top of the viewport and streams the reply into reserved end space below it, instead of jumping to the bottom and chasing it. - swap @tanstack/react-virtual for @legendapp/list in the chat timeline; the streaming tail becomes a normal list row rather than a separately rendered block, so one component owns the scroll position - add timelineScrollAnchoring: pure anchored-turn geometry plus the three scroll modes (following-end / anchoring-new-turn / free-scrolling) - replace useChatAutoFollow with useChatTimelineScroll, which opts out of automatic movement on real gestures via a generation counter instead of the timer windows the old implementation needed to recognise its own writes - move the load-older button, question/permission cards, recap, status row and bottom spacer into the list header/footer, since the list owns its container - extract useScrollShadow so the shadows can attach to that container maintainScrollAtEnd and maintainVisibleContentPosition replace the manual prepend anchor-hold and the mobile quiet-window prepend deferral. Validated: workspace type-check, lint, web build, ui tests per file. Scroll behaviour itself is unverified and needs manual testing on web, desktop and iOS. --- bun.lock | 3 + packages/ui/package.json | 1 + .../ui/src/components/chat/ChatContainer.tsx | 231 +++-- .../ui/src/components/chat/ChatMessage.tsx | 2 +- .../ui/src/components/chat/MessageList.tsx | 750 +++++++------- .../chat/components/TurnActivity.tsx | 2 +- .../scroll/timelineScrollAnchoring.test.ts | 225 +++++ .../lib/scroll/timelineScrollAnchoring.ts | 147 +++ .../components/chat/message/MessageBody.tsx | 2 +- .../chat/message/parts/AssistantTextPart.tsx | 2 +- .../chat/message/parts/JustificationBlock.tsx | 2 +- .../chat/message/parts/ProgressiveGroup.tsx | 2 +- .../chat/message/parts/ReasoningPart.tsx | 2 +- .../chat/message/parts/ToolPart.tsx | 2 +- .../ui/src/components/ui/ScrollShadow.tsx | 114 +-- .../ui/src/components/ui/useScrollShadow.ts | 159 +++ packages/ui/src/hooks/useChatAutoFollow.ts | 938 ------------------ .../ui/src/hooks/useChatTimelineScroll.ts | 691 +++++++++++++ 18 files changed, 1739 insertions(+), 1536 deletions(-) create mode 100644 packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts create mode 100644 packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts create mode 100644 packages/ui/src/components/ui/useScrollShadow.ts delete mode 100644 packages/ui/src/hooks/useChatAutoFollow.ts create mode 100644 packages/ui/src/hooks/useChatTimelineScroll.ts diff --git a/bun.lock b/bun.lock index 98e47d8f..d9713441 100644 --- a/bun.lock +++ b/bun.lock @@ -167,6 +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", "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "1.18.21", "@pierre/diffs": "1.3.0-beta.6", @@ -917,6 +918,8 @@ "@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=="], + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], "@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index 14eaf104..dc5e40f1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -43,6 +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", "@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 a6384531..9174668b 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -18,8 +18,8 @@ import { StatusRowContainer } from './StatusRowContainer'; import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer'; import ScrollToBottomButton from './components/ScrollToBottomButton'; import { PromptNavigatorRail } from './components/PromptNavigatorRail'; -import { ScrollShadow } from '@/components/ui/ScrollShadow'; -import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow'; +import { useScrollShadow } from '@/components/ui/useScrollShadow'; +import { useChatTimelineScroll, type AnimationHandlers, type ContentChangeReason, type TimelineListHandle } from '@/hooks/useChatTimelineScroll'; import { useChatTimelineController } from './hooks/useChatTimelineController'; import { TimelineDialog } from './TimelineDialog'; import { useChatTurnNavigation } from './hooks/useChatTurnNavigation'; @@ -151,10 +151,16 @@ type ChatViewportProps = { currentSessionKey: string; isDesktopExpandedInput: boolean; isMobile: boolean; - stickyUserHeader: boolean; directory?: string; scrollRef: React.RefObject; messageListRef: React.RefObject; + registerList: (list: TimelineListHandle | null) => void; + anchorMessageId: string | null; + onAnchorReady: (messageId: string, anchorIndex: number) => void; + onAnchorSizeChanged: (messageId: string) => void; + composerOverlayHeight: number; + onIsAtEndChange: (isAtEnd: boolean) => void; + onTimelineDataChange: () => void; pendingRevealWork: boolean; renderedMessages: SessionMessageRecord[]; isLoadingOlder: boolean; @@ -169,7 +175,6 @@ type ChatViewportProps = { } | null; handleMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; - handleHistoryScroll: () => void; scrollToBottom: () => void; sessionQuestions: QuestionRequest[]; sessionPermissions: PermissionRequest[]; @@ -190,10 +195,16 @@ const ChatViewport = React.memo(({ currentSessionKey, isDesktopExpandedInput, isMobile, - stickyUserHeader, directory, scrollRef, messageListRef, + registerList, + anchorMessageId, + onAnchorReady, + onAnchorSizeChanged, + composerOverlayHeight, + onIsAtEndChange, + onTimelineDataChange, pendingRevealWork, renderedMessages, isLoadingOlder, @@ -203,7 +214,6 @@ const ChatViewport = React.memo(({ retryOverlay, handleMessageContentChange, getAnimationHandlers, - handleHistoryScroll, scrollToBottom, sessionQuestions, sessionPermissions, @@ -315,6 +325,60 @@ const ChatViewport = React.memo(({ scrollRef.current?.focus({ preventScroll: true }); }, [scrollRef]); + // Everything that used to sit beside the list inside the scroll container + // now renders as the list's header/footer, so it keeps scrolling with the + // rows exactly as before. + const listHeader = React.useMemo(() => ( + showLoadOlderButton ? ( +
+ +
+ ) : null + ), [isLoadingOlder, onLoadOlder, showLoadOlderButton, t]); + + const listFooter = React.useMemo(() => ( + <> + {(sessionQuestions.length > 0 || sessionPermissions.length > 0) && ( +
+ {sessionQuestions.map((question) => ( + + ))} + {sessionPermissions.map((permission) => ( + + ))} +
+ )} + + + +
+ +
+ +