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.
This commit is contained in:
@@ -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<HTMLDivElement | null>;
|
||||
messageListRef: React.RefObject<MessageListHandle | null>;
|
||||
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 ? (
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onLoadOlder}
|
||||
disabled={isLoadingOlder}
|
||||
>
|
||||
{isLoadingOlder && (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
)}
|
||||
{t('chat.history.loadOlder')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
), [isLoadingOlder, onLoadOlder, showLoadOlderButton, t]);
|
||||
|
||||
const listFooter = React.useMemo(() => (
|
||||
<>
|
||||
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
|
||||
<div>
|
||||
{sessionQuestions.map((question) => (
|
||||
<QuestionCard key={question.id} question={question} />
|
||||
))}
|
||||
{sessionPermissions.map((permission) => (
|
||||
<PermissionCard key={permission.id} permission={permission} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
|
||||
|
||||
<div className="mb-3">
|
||||
<StatusRowContainer />
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
||||
</>
|
||||
), [currentSessionId, directory, isMobile, sessionPermissions, sessionQuestions]);
|
||||
|
||||
const scrollContainerProps = React.useMemo(() => ({
|
||||
className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target',
|
||||
style: CHAT_SCROLL_STYLE,
|
||||
tabIndex: 0,
|
||||
onClick: focusScrollContainer,
|
||||
'data-scrollbar': 'chat',
|
||||
'data-scroll-shadow': 'true',
|
||||
'data-orientation': 'vertical',
|
||||
}), [focusScrollContainer]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -326,71 +390,32 @@ const ChatViewport = React.memo(({
|
||||
aria-hidden={isDesktopExpandedInput}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<ScrollShadow
|
||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
||||
ref={scrollRef}
|
||||
style={CHAT_SCROLL_STYLE}
|
||||
observeMutations={false}
|
||||
hideTopShadow={isMobile && stickyUserHeader}
|
||||
tabIndex={0}
|
||||
onClick={focusScrollContainer}
|
||||
onScroll={handleHistoryScroll}
|
||||
data-scroll-shadow="true"
|
||||
data-scrollbar="chat"
|
||||
>
|
||||
<div className="relative z-0 min-h-full">
|
||||
{showLoadOlderButton && (
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onLoadOlder}
|
||||
disabled={isLoadingOlder}
|
||||
>
|
||||
{isLoadingOlder && (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
)}
|
||||
{t('chat.history.loadOlder')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<MessageList
|
||||
key={currentSessionKey}
|
||||
ref={messageListRef}
|
||||
sessionKey={currentSessionId}
|
||||
disableStaging={pendingRevealWork}
|
||||
messages={renderedMessages}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
activeStreamingMessageId={streamingMessageId}
|
||||
activeStreamingPhase={activeStreamingPhase}
|
||||
retryOverlay={retryOverlay}
|
||||
onMessageContentChange={handleMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
isLoadingOlder={isLoadingOlder}
|
||||
scrollToBottom={scrollToBottom}
|
||||
scrollRef={scrollRef}
|
||||
directory={directory}
|
||||
/>
|
||||
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
|
||||
<div>
|
||||
{sessionQuestions.map((question) => (
|
||||
<QuestionCard key={question.id} question={question} />
|
||||
))}
|
||||
{sessionPermissions.map((permission) => (
|
||||
<PermissionCard key={permission.id} permission={permission} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
|
||||
|
||||
<div className="mb-3">
|
||||
<StatusRowContainer />
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
<MessageList
|
||||
key={currentSessionKey}
|
||||
ref={messageListRef}
|
||||
sessionKey={currentSessionId}
|
||||
disableStaging={pendingRevealWork}
|
||||
messages={renderedMessages}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
activeStreamingMessageId={streamingMessageId}
|
||||
activeStreamingPhase={activeStreamingPhase}
|
||||
retryOverlay={retryOverlay}
|
||||
onMessageContentChange={handleMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
isLoadingOlder={isLoadingOlder}
|
||||
scrollToBottom={scrollToBottom}
|
||||
directory={directory}
|
||||
registerList={registerList}
|
||||
anchorMessageId={anchorMessageId}
|
||||
onAnchorReady={onAnchorReady}
|
||||
onAnchorSizeChanged={onAnchorSizeChanged}
|
||||
composerOverlayHeight={composerOverlayHeight}
|
||||
onIsAtEndChange={onIsAtEndChange}
|
||||
onTimelineDataChange={onTimelineDataChange}
|
||||
listHeader={listHeader}
|
||||
listFooter={listFooter}
|
||||
scrollContainerProps={scrollContainerProps}
|
||||
/>
|
||||
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
|
||||
{showPromptNavigator && promptTurnIds.length >= 2 ? (
|
||||
<PromptNavigatorRail
|
||||
@@ -411,7 +436,6 @@ const ChatViewport = React.memo(({
|
||||
&& prev.currentSessionKey === next.currentSessionKey
|
||||
&& prev.isDesktopExpandedInput === next.isDesktopExpandedInput
|
||||
&& prev.isMobile === next.isMobile
|
||||
&& prev.stickyUserHeader === next.stickyUserHeader
|
||||
&& prev.directory === next.directory
|
||||
&& prev.scrollRef === next.scrollRef
|
||||
&& prev.messageListRef === next.messageListRef
|
||||
@@ -424,7 +448,6 @@ const ChatViewport = React.memo(({
|
||||
&& prev.retryOverlay === next.retryOverlay
|
||||
&& prev.handleMessageContentChange === next.handleMessageContentChange
|
||||
&& prev.getAnimationHandlers === next.getAnimationHandlers
|
||||
&& prev.handleHistoryScroll === next.handleHistoryScroll
|
||||
&& prev.scrollToBottom === next.scrollToBottom
|
||||
&& prev.sessionQuestions === next.sessionQuestions
|
||||
&& prev.sessionPermissions === next.sessionPermissions
|
||||
@@ -891,23 +914,44 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
activeTurnChangeRef.current(turnId);
|
||||
}, []);
|
||||
|
||||
// The composer sits below the timeline rather than over it, so no part of
|
||||
// the scroll container is occluded. Mobile surfaces that float the composer
|
||||
// pass their measured height here instead.
|
||||
const composerOverlayHeight = 0;
|
||||
const lastUserMessageId = React.useMemo(() => {
|
||||
for (let index = sessionMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = sessionMessages[index];
|
||||
if (message.info.role === 'user') {
|
||||
return message.info.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [sessionMessages]);
|
||||
|
||||
const {
|
||||
scrollRef,
|
||||
scrollNode,
|
||||
registerList,
|
||||
anchorMessageId,
|
||||
onAnchorReady,
|
||||
onAnchorSizeChanged,
|
||||
onIsAtEndChange,
|
||||
onManualNavigation,
|
||||
onTimelineDataChange,
|
||||
notifyContentChange: handleMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
goToBottom,
|
||||
scrollToBottomOnSend,
|
||||
releaseAutoFollow,
|
||||
restoreSnapshot,
|
||||
isPinned,
|
||||
isFollowingProgrammatically,
|
||||
showScrollButton,
|
||||
} = useChatAutoFollow({
|
||||
} = useChatTimelineScroll({
|
||||
currentSessionId,
|
||||
currentSessionKey,
|
||||
sessionMessageCount,
|
||||
sessionIsWorking,
|
||||
isMobile,
|
||||
composerOverlayHeight,
|
||||
lastUserMessageId,
|
||||
onActiveTurnChange: handleActiveTurnChange,
|
||||
});
|
||||
|
||||
@@ -922,10 +966,28 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
messageListRef,
|
||||
loadMoreMessages,
|
||||
goToBottom,
|
||||
releaseAutoFollow,
|
||||
releaseAutoFollow: onManualNavigation,
|
||||
isPinned,
|
||||
showScrollButton,
|
||||
});
|
||||
// The list owns the scroll element, so the shadows and the load-older
|
||||
// trigger bind to its node rather than to a wrapper we render.
|
||||
const scrollNodeRef = React.useMemo(() => ({ current: scrollNode }), [scrollNode]);
|
||||
useScrollShadow(scrollNodeRef, {
|
||||
observeMutations: false,
|
||||
hideTopShadow: isMobile && stickyUserHeader,
|
||||
});
|
||||
|
||||
const handleHistoryScroll = timelineController.handleHistoryScroll;
|
||||
React.useEffect(() => {
|
||||
if (!scrollNode) return;
|
||||
const onScroll = () => handleHistoryScroll();
|
||||
scrollNode.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
scrollNode.removeEventListener('scroll', onScroll);
|
||||
};
|
||||
}, [handleHistoryScroll, scrollNode]);
|
||||
|
||||
const resumeToLatestInstant = React.useCallback(() => {
|
||||
goToBottom('instant');
|
||||
}, [goToBottom]);
|
||||
@@ -1084,7 +1146,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
lastScrolledSessionKeyRef.current = currentSessionKey;
|
||||
if (hasHashTarget) {
|
||||
// Hash navigation handler will scroll to target; we just release auto-follow.
|
||||
releaseAutoFollow();
|
||||
onManualNavigation();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1096,7 +1158,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
} else {
|
||||
window.requestAnimationFrame(run);
|
||||
}
|
||||
}, [active, currentSessionId, currentSessionKey, releaseAutoFollow, restoreSnapshot]);
|
||||
}, [active, currentSessionId, currentSessionKey, onManualNavigation, restoreSnapshot]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!messagesEnabled || !currentSessionId) return;
|
||||
@@ -1265,9 +1327,15 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
currentSessionKey={currentSessionKey ?? currentSessionId ?? ''}
|
||||
isDesktopExpandedInput={isDesktopExpandedInput}
|
||||
isMobile={isMobile}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
directory={effectiveSessionDirectory}
|
||||
scrollRef={scrollRef}
|
||||
registerList={registerList}
|
||||
anchorMessageId={anchorMessageId}
|
||||
onAnchorReady={onAnchorReady}
|
||||
onAnchorSizeChanged={onAnchorSizeChanged}
|
||||
composerOverlayHeight={composerOverlayHeight}
|
||||
onIsAtEndChange={onIsAtEndChange}
|
||||
onTimelineDataChange={onTimelineDataChange}
|
||||
messageListRef={messageListRef}
|
||||
pendingRevealWork={timelineController.pendingRevealWork}
|
||||
renderedMessages={timelineController.renderedMessages}
|
||||
@@ -1278,7 +1346,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
retryOverlay={retryOverlay}
|
||||
handleMessageContentChange={handleMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
handleHistoryScroll={timelineController.handleHistoryScroll}
|
||||
scrollToBottom={resumeToLatestInstant}
|
||||
sessionQuestions={sessionQuestions}
|
||||
sessionPermissions={sessionPermissions}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useChatSurfaceMode } from './useChatSurfaceMode';
|
||||
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatTimelineScroll';
|
||||
import MessageBody from './message/MessageBody';
|
||||
import type { AgentMentionInfo } from './message/types';
|
||||
import type { StreamPhase, ToolPopupContent } from './message/types';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { elementScroll, useVirtualizer as useTanstackVirtualizer, type ReactVirtualizer, type VirtualItem } from '@tanstack/react-virtual';
|
||||
import { LegendList, type LegendListRef } from '@legendapp/list/react';
|
||||
|
||||
import ChatMessage from './ChatMessage';
|
||||
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
|
||||
import TurnItem from './components/TurnItem';
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatTimelineScroll';
|
||||
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
|
||||
import { useTurnRecords } from './hooks/useTurnRecords';
|
||||
import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
|
||||
@@ -20,8 +20,8 @@ import { streamPerfCount, streamPerfMark, streamPerfMeasure } from '@/stores/uti
|
||||
import type { StreamPhase } from './message/types';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useSessionParts } from '@/sync/sync-context';
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
|
||||
import { resolveChatListAnchoredEndSpace, resolveTimelineIsAtEnd } from './lib/scroll/timelineScrollAnchoring';
|
||||
import {
|
||||
USER_SHELL_MARKER,
|
||||
isUserShellMarkerMessage,
|
||||
@@ -29,96 +29,52 @@ import {
|
||||
type ShellBridgeDetails,
|
||||
} from './lib/shellBridge';
|
||||
|
||||
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
|
||||
const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = [];
|
||||
const EMPTY_UNGROUPED_MESSAGE_IDS = new Set<string>();
|
||||
const TIMELINE_CACHE_LIMIT = 16;
|
||||
|
||||
const sameKeys = (a: readonly string[] | undefined, b: readonly string[] | undefined): boolean => {
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return false;
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((key, index) => key === b[index]);
|
||||
};
|
||||
// --- Timeline virtualization (@legendapp/list) -----------------------------
|
||||
// The timeline is a single virtualized list on every surface: history turns
|
||||
// AND the live streaming tail are rows of the same list, so the list owns one
|
||||
// coherent scroll position instead of arbitrating between a virtualizer and a
|
||||
// separately-rendered tail.
|
||||
//
|
||||
// Scroll behavior the list owns natively, which is why none of it exists here
|
||||
// any more:
|
||||
// • `maintainScrollAtEnd` keeps the live edge pinned as rows grow.
|
||||
// • `maintainVisibleContentPosition` preserves the read position when older
|
||||
// history is prepended, replacing the manual anchor-hold and the mobile
|
||||
// quiet-window prepend deferral.
|
||||
// • `anchoredEndSpace` reserves the tail space that parks a just-sent
|
||||
// message near the top of the viewport.
|
||||
const TIMELINE_ESTIMATED_ENTRY_SIZE = 320;
|
||||
|
||||
// --- History virtualization (@tanstack/react-virtual) ----------------------
|
||||
// The history list virtualizes with @tanstack/react-virtual on all surfaces:
|
||||
// its core has bottom anchoring (anchorTo: 'end'), key-stable prepend
|
||||
// preservation, and native iOS touch/momentum deferral for scroll
|
||||
// adjustments — the failure modes that historically forced virtua off on
|
||||
// mobile and required manual prepend compensation on desktop.
|
||||
type TanstackVirtualizerInstance = ReactVirtualizer<HTMLDivElement, HTMLDivElement>;
|
||||
type HistoryEngine = 'none' | 'tanstack';
|
||||
|
||||
const TANSTACK_ESTIMATED_ENTRY_SIZE = 320;
|
||||
const TANSTACK_OVERSCAN = 8;
|
||||
// Touch flings cover more distance between paints than desktop wheels; a
|
||||
// larger window keeps fast mobile scrolling over mounted rows.
|
||||
const TANSTACK_MOBILE_OVERSCAN = 16;
|
||||
const resolveTanstackOverscan = (): number => (
|
||||
isMobileSurfaceRuntime() ? TANSTACK_MOBILE_OVERSCAN : TANSTACK_OVERSCAN
|
||||
);
|
||||
// Post-prepend anchor hold: measurements of freshly
|
||||
// prepended rows settle over multiple frames, so a single restore can be
|
||||
// invalidated by the next measurement pass. Re-assert the anchor until it
|
||||
// holds still for STABLE_FRAMES consecutive frames, giving up at MAX_FRAMES.
|
||||
// Anchor hold for an explicit viewport restore (session re-entry): row
|
||||
// measurements settle over several frames, so a single restore can be
|
||||
// invalidated by the next measurement pass. Re-assert until it holds still for
|
||||
// STABLE_FRAMES consecutive frames, giving up at MAX_FRAMES.
|
||||
const ANCHOR_HOLD_STABLE_FRAMES = 30;
|
||||
const ANCHOR_HOLD_MAX_FRAMES = 180;
|
||||
// Adaptive estimate bounds: only trust the session average once a few rows
|
||||
// are measured, and keep it inside sane turn-height bounds.
|
||||
const TANSTACK_ESTIMATE_MIN_SAMPLES = 5;
|
||||
const TANSTACK_ESTIMATE_MIN = 120;
|
||||
const TANSTACK_ESTIMATE_MAX = 1200;
|
||||
// "At bottom" tolerance for resize-adjustment decisions.
|
||||
const TANSTACK_AT_END_THRESHOLD_PX = 80;
|
||||
|
||||
// Quiet-window prepend on mobile: while a touch drag or momentum scroll is
|
||||
// active, iOS owns the scroll position and ANY geometry change above the
|
||||
// viewport races against the native animation — a race that compensation
|
||||
// logic can only lose sometimes. So freshly loaded older history is held
|
||||
// (data already fetched, store already updated) and inserted into the
|
||||
// rendered list only once the gesture goes quiet. Safety valves: flush when
|
||||
// the user gets close to the top (a blank top is worse than a small hop) or
|
||||
// after MAX_HOLD_MS.
|
||||
const HISTORY_PREPEND_QUIET_MS = 160;
|
||||
const HISTORY_PREPEND_MAX_HOLD_MS = 1500;
|
||||
const HISTORY_PREPEND_NEAR_TOP_VIEWPORTS = 1.5;
|
||||
const HISTORY_PREPEND_MONITOR_INTERVAL_MS = 90;
|
||||
|
||||
// A commit is a deferable prepend when older entries were inserted strictly
|
||||
// above the known content: the previous first key still exists deeper in the
|
||||
// list and the tail is unchanged. Anything else renders immediately.
|
||||
const isPrependAboveCommit = (previous: RenderEntry[], next: RenderEntry[]): boolean => {
|
||||
if (previous.length === 0 || next.length <= previous.length) return false;
|
||||
if (previous[previous.length - 1]?.key !== next[next.length - 1]?.key) return false;
|
||||
const previousFirstKey = previous[0]?.key;
|
||||
const insertedIndex = next.findIndex((entry) => entry.key === previousFirstKey);
|
||||
return insertedIndex > 0;
|
||||
// Reserved tail space that parks an anchored row near the top of the viewport.
|
||||
// `onReady` fires once the list has measured the anchor, `onSizeChanged` when
|
||||
// the reserved size is recomputed.
|
||||
// Presentation-only props forwarded to the scroll container the list renders.
|
||||
// Deliberately narrow: the list owns scroll and layout callbacks on that
|
||||
// element, so only styling, focus and click-through are caller-controlled.
|
||||
type TimelineScrollContainerProps = {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
tabIndex?: number;
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
'data-scrollbar'?: string;
|
||||
'data-scroll-shadow'?: string;
|
||||
};
|
||||
|
||||
const tanstackTimelineCache = new Map<string, { keys: readonly string[]; items: VirtualItem[] }>();
|
||||
|
||||
const readTanstackTimelineCache = (sessionKey: string, keys: readonly string[]): VirtualItem[] | undefined => {
|
||||
const entry = tanstackTimelineCache.get(sessionKey);
|
||||
if (!entry) return undefined;
|
||||
if (sameKeys(entry.keys, keys)) return entry.items;
|
||||
tanstackTimelineCache.delete(sessionKey);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const writeTanstackTimelineCache = (
|
||||
sessionKey: string,
|
||||
keys: readonly string[],
|
||||
virtualizer: TanstackVirtualizerInstance | null | undefined,
|
||||
): void => {
|
||||
if (!virtualizer || keys.length === 0) return;
|
||||
tanstackTimelineCache.delete(sessionKey);
|
||||
tanstackTimelineCache.set(sessionKey, { keys: keys.slice(), items: virtualizer.takeSnapshot() });
|
||||
while (tanstackTimelineCache.size > TIMELINE_CACHE_LIMIT) {
|
||||
const oldest = tanstackTimelineCache.keys().next().value;
|
||||
if (typeof oldest !== 'string') break;
|
||||
tanstackTimelineCache.delete(oldest);
|
||||
}
|
||||
type TimelineAnchoredEndSpace = {
|
||||
anchorIndex: number;
|
||||
anchorOffset?: number;
|
||||
onReady?: (info: { anchorIndex: number | undefined; anchorKey: string | undefined; size: number }) => void;
|
||||
onSizeChanged?: (size: number) => void;
|
||||
};
|
||||
|
||||
const useStableEvent = <TArgs extends unknown[], TResult>(handler: (...args: TArgs) => TResult) => {
|
||||
@@ -365,8 +321,24 @@ interface MessageListProps {
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
isLoadingOlder: boolean;
|
||||
scrollToBottom?: () => void;
|
||||
scrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||
directory?: string;
|
||||
// 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;
|
||||
// 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;
|
||||
onAnchorReady?: (messageId: string, anchorIndex: number) => void;
|
||||
onAnchorSizeChanged?: (messageId: string) => void;
|
||||
composerOverlayHeight?: number;
|
||||
onIsAtEndChange?: (isAtEnd: boolean) => void;
|
||||
onTimelineDataChange?: () => void;
|
||||
// Content that used to sit as siblings of the list inside the scroll
|
||||
// container. The list owns that container now, so they render as its
|
||||
// header/footer and scroll with the rows exactly as before.
|
||||
listHeader?: React.ReactNode;
|
||||
listFooter?: React.ReactNode;
|
||||
scrollContainerProps?: TimelineScrollContainerProps;
|
||||
}
|
||||
|
||||
export interface MessageListHandle {
|
||||
@@ -929,14 +901,10 @@ const MessageListEntry = React.memo(({
|
||||
|
||||
MessageListEntry.displayName = 'MessageListEntry';
|
||||
|
||||
// Inner component that renders staged turn entries.
|
||||
type StaticHistoryListProps = {
|
||||
entries: RenderEntry[];
|
||||
engine: HistoryEngine;
|
||||
contentRef: React.RefObject<HTMLDivElement | null>;
|
||||
scrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||
registerTanstackVirtualizer?: (virtualizer: TanstackVirtualizerInstance | null) => void;
|
||||
virtualizerKey: string;
|
||||
// Shared row state. Passed through context rather than closed over by
|
||||
// `renderItem` so the render callback keeps a stable identity — a changing
|
||||
// `renderItem` makes the list re-render every mounted row on every commit.
|
||||
type TimelineRowContextValue = {
|
||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
scrollToBottom?: () => void;
|
||||
@@ -945,242 +913,176 @@ type StaticHistoryListProps = {
|
||||
turnUiStates: Map<string, TurnUiState>;
|
||||
onToggleTurnGroup: (turnId: string) => void;
|
||||
chatRenderMode: 'sorted' | 'live';
|
||||
showTurnChangedFiles: boolean;
|
||||
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
|
||||
onUserAnimationConsumed: (messageId: string) => void;
|
||||
reviewTransferDirection?: ReviewTransferDirection | null;
|
||||
// The live tail row renders through StreamingTailContent, which subscribes
|
||||
// to streaming parts; every other row renders statically.
|
||||
streamingTailKey: string | null;
|
||||
directory?: string;
|
||||
sessionIsWorking: boolean;
|
||||
activeStreamingMessageId: string | null;
|
||||
activeStreamingPhase: StreamPhase | null;
|
||||
};
|
||||
|
||||
const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef, registerTanstackVirtualizer, virtualizerKey, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => {
|
||||
const isTanstack = engine === 'tanstack';
|
||||
const TimelineRowContext = React.createContext<TimelineRowContextValue | null>(null);
|
||||
|
||||
// --- Quiet-window prepend (mobile) --------------------------------------
|
||||
// Gesture tracking for the deferred-prepend decision. Refs only: reading
|
||||
// them never re-renders, and the render-phase reconcile below needs them.
|
||||
const touchActiveRef = React.useRef(false);
|
||||
const lastScrollAtRef = React.useRef(0);
|
||||
const holdSinceRef = React.useRef<number | null>(null);
|
||||
const deferPrepends = isTanstack && isMobileSurfaceRuntime();
|
||||
const TimelineRow = React.memo(({ entry }: { entry: RenderEntry }) => {
|
||||
const context = React.useContext(TimelineRowContext);
|
||||
if (!context) return null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!deferPrepends) return;
|
||||
const element = scrollRef?.current;
|
||||
if (!element) return;
|
||||
const onTouchStart = () => { touchActiveRef.current = true; };
|
||||
const onTouchEnd = () => { touchActiveRef.current = false; };
|
||||
const onScroll = () => { lastScrollAtRef.current = performance.now(); };
|
||||
element.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
element.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
element.addEventListener('touchcancel', onTouchEnd, { passive: true });
|
||||
element.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
element.removeEventListener('touchstart', onTouchStart);
|
||||
element.removeEventListener('touchend', onTouchEnd);
|
||||
element.removeEventListener('touchcancel', onTouchEnd);
|
||||
element.removeEventListener('scroll', onScroll);
|
||||
};
|
||||
}, [deferPrepends, scrollRef]);
|
||||
|
||||
const isGestureActive = React.useCallback(() => (
|
||||
touchActiveRef.current
|
||||
|| performance.now() - lastScrollAtRef.current < HISTORY_PREPEND_QUIET_MS
|
||||
), []);
|
||||
|
||||
const isNearTop = React.useCallback(() => {
|
||||
const element = scrollRef?.current;
|
||||
if (!element) return true;
|
||||
return element.scrollTop < element.clientHeight * HISTORY_PREPEND_NEAR_TOP_VIEWPORTS;
|
||||
}, [scrollRef]);
|
||||
|
||||
const [displayEntries, setDisplayEntries] = React.useState(entries);
|
||||
// Render-phase reconcile (official derived-state pattern): adopt the new
|
||||
// entries immediately unless this commit is a pure prepend-above landing
|
||||
// in the middle of an active touch gesture — those wait for quiet.
|
||||
let renderEntries = displayEntries;
|
||||
if (entries !== displayEntries) {
|
||||
const shouldHold = deferPrepends
|
||||
&& isPrependAboveCommit(displayEntries, entries)
|
||||
&& isGestureActive()
|
||||
&& !isNearTop()
|
||||
&& (holdSinceRef.current === null
|
||||
|| performance.now() - holdSinceRef.current < HISTORY_PREPEND_MAX_HOLD_MS);
|
||||
if (shouldHold) {
|
||||
if (holdSinceRef.current === null) holdSinceRef.current = performance.now();
|
||||
} else {
|
||||
holdSinceRef.current = null;
|
||||
setDisplayEntries(entries);
|
||||
renderEntries = entries;
|
||||
}
|
||||
} else if (holdSinceRef.current !== null) {
|
||||
holdSinceRef.current = null;
|
||||
}
|
||||
|
||||
// While a prepend is held, poll for the quiet window (touch/momentum have
|
||||
// no completion event we can await) and flush by re-rendering.
|
||||
const [, forceFlushTick] = React.useReducer((tick: number) => tick + 1, 0);
|
||||
React.useEffect(() => {
|
||||
if (!deferPrepends) return;
|
||||
const timer = window.setInterval(() => {
|
||||
if (holdSinceRef.current === null) return;
|
||||
const expired = performance.now() - holdSinceRef.current >= HISTORY_PREPEND_MAX_HOLD_MS;
|
||||
if (!isGestureActive() || isNearTop() || expired) {
|
||||
forceFlushTick();
|
||||
}
|
||||
}, HISTORY_PREPEND_MONITOR_INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [deferPrepends, isGestureActive, isNearTop]);
|
||||
|
||||
const entriesRef = React.useRef(renderEntries);
|
||||
entriesRef.current = renderEntries;
|
||||
// Initial-only read: measurement cache restore is a mount-time concern;
|
||||
// afterwards the live virtualizer owns measurements.
|
||||
const [initialMeasurements] = React.useState(() => (
|
||||
isTanstack
|
||||
? readTanstackTimelineCache(virtualizerKey, entries.map((entry) => entry.key))
|
||||
: undefined
|
||||
));
|
||||
|
||||
const sizeContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
// Adaptive estimate: rows this session has actually measured are a far
|
||||
// better predictor for the still-unmeasured ones than a fixed constant.
|
||||
// Smaller estimate error → smaller anchor corrections when prepended rows
|
||||
// measure in → less visible drift. The ref keeps estimateSize's identity
|
||||
// stable so updating the average never triggers a global remeasure.
|
||||
const estimatedEntrySizeRef = React.useRef(TANSTACK_ESTIMATED_ENTRY_SIZE);
|
||||
const tanstackVirtualizer = useTanstackVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
count: renderEntries.length,
|
||||
enabled: isTanstack,
|
||||
getScrollElement: () => scrollRef?.current ?? null,
|
||||
estimateSize: () => estimatedEntrySizeRef.current,
|
||||
overscan: resolveTanstackOverscan(),
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
// Expose the new total height before core writes an anchor
|
||||
// correction so the browser does not clamp the offset to the old
|
||||
// height.
|
||||
const sizeElement = sizeContainerRef.current;
|
||||
if (sizeElement) sizeElement.style.height = `${instance.getTotalSize()}px`;
|
||||
elementScroll(offset, options, instance);
|
||||
},
|
||||
getItemKey: (index) => entriesRef.current[index]?.key ?? `index:${index}`,
|
||||
// Bottom-anchored chat semantics: prepending older entries above the
|
||||
// viewport must not move what the user is reading, and iOS-specific
|
||||
// touch/momentum deferral for those adjustments lives in the core.
|
||||
anchorTo: 'end',
|
||||
initialOffset: () => Number.MAX_SAFE_INTEGER,
|
||||
initialMeasurementsCache: initialMeasurements,
|
||||
});
|
||||
// Only compensate scroll for rows growing ABOVE the viewport (history
|
||||
// remeasures, prepended pages). A row growing inside the viewport —
|
||||
// expanding a tool call or thinking block — must grow DOWNWARD naturally;
|
||||
// the end-anchored default made it expand upward. At the bottom,
|
||||
// app-level auto-follow owns pinning, so skip there too instead of
|
||||
// double-writing. (This is an instance field, not a constructor option.)
|
||||
tanstackVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
|
||||
if (instance.isAtEnd(TANSTACK_AT_END_THRESHOLD_PX)) return false;
|
||||
const firstVisibleIndex = instance.range?.startIndex;
|
||||
return firstVisibleIndex !== undefined && item.index < firstVisibleIndex;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTanstack) return;
|
||||
const sizes = tanstackVirtualizer.itemSizeCache;
|
||||
if (sizes.size >= TANSTACK_ESTIMATE_MIN_SAMPLES) {
|
||||
let total = 0;
|
||||
for (const size of sizes.values()) total += size;
|
||||
estimatedEntrySizeRef.current = Math.min(
|
||||
TANSTACK_ESTIMATE_MAX,
|
||||
Math.max(TANSTACK_ESTIMATE_MIN, Math.round(total / sizes.size)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTanstack) return;
|
||||
registerTanstackVirtualizer?.(tanstackVirtualizer);
|
||||
return () => {
|
||||
writeTanstackTimelineCache(
|
||||
virtualizerKey,
|
||||
entriesRef.current.map((entry) => entry.key),
|
||||
tanstackVirtualizer,
|
||||
);
|
||||
registerTanstackVirtualizer?.(null);
|
||||
};
|
||||
}, [isTanstack, registerTanstackVirtualizer, tanstackVirtualizer, virtualizerKey]);
|
||||
|
||||
const renderEntry = React.useCallback((entry: RenderEntry) => {
|
||||
if (context.streamingTailKey === entry.key) {
|
||||
return (
|
||||
<MessageListEntry
|
||||
key={entry.key}
|
||||
<StreamingTailContent
|
||||
entry={entry}
|
||||
onMessageContentChange={onMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
scrollToBottom={scrollToBottom}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
sessionIsWorking={false}
|
||||
defaultActivityExpanded={defaultActivityExpanded}
|
||||
turnUiStates={turnUiStates}
|
||||
onToggleTurnGroup={onToggleTurnGroup}
|
||||
chatRenderMode={chatRenderMode}
|
||||
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
||||
onUserAnimationConsumed={onUserAnimationConsumed}
|
||||
activeStreamingMessageId={null}
|
||||
activeStreamingPhase={null}
|
||||
reviewTransferDirection={reviewTransferDirection}
|
||||
directory={context.directory}
|
||||
onMessageContentChange={context.onMessageContentChange}
|
||||
getAnimationHandlers={context.getAnimationHandlers}
|
||||
scrollToBottom={context.scrollToBottom}
|
||||
stickyUserHeader={context.stickyUserHeader}
|
||||
sessionIsWorking={context.sessionIsWorking}
|
||||
defaultActivityExpanded={context.defaultActivityExpanded}
|
||||
turnUiStates={context.turnUiStates}
|
||||
onToggleTurnGroup={context.onToggleTurnGroup}
|
||||
chatRenderMode={context.chatRenderMode}
|
||||
showTurnChangedFiles={context.showTurnChangedFiles}
|
||||
shouldAnimateUserMessage={context.shouldAnimateUserMessage}
|
||||
onUserAnimationConsumed={context.onUserAnimationConsumed}
|
||||
activeStreamingMessageId={context.activeStreamingMessageId}
|
||||
activeStreamingPhase={context.activeStreamingPhase}
|
||||
reviewTransferDirection={context.reviewTransferDirection}
|
||||
/>
|
||||
);
|
||||
}, [chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, reviewTransferDirection, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
|
||||
|
||||
if (engine === 'none') {
|
||||
return (
|
||||
<div ref={contentRef} className="relative w-full">
|
||||
{renderEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
data-turn-entry={entry.key}
|
||||
>
|
||||
{renderEntry(entry)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (engine === 'tanstack') {
|
||||
const virtualItems = tanstackVirtualizer.getVirtualItems();
|
||||
const startOffset = virtualItems[0]?.start ?? 0;
|
||||
// Rendered rows stay in normal flow inside a single offset wrapper (not
|
||||
// per-row absolute positioning) so per-turn sticky user headers keep
|
||||
// working against the scroll container. The offset MUST be padding, not
|
||||
// transform: a transformed ancestor becomes the sticky containing block,
|
||||
// so headers would stick to the wrapper's (arbitrary, overscan-dependent)
|
||||
// top edge mid-list and float over the previous turn. Padding only
|
||||
// changes when the virtual window shifts — not per scroll frame — so the
|
||||
// layout cost is negligible.
|
||||
return (
|
||||
<div ref={sizeContainerRef} className="relative w-full" style={{ height: tanstackVirtualizer.getTotalSize() }}>
|
||||
<div style={{ paddingTop: `${startOffset}px` }}>
|
||||
{virtualItems.map((item) => {
|
||||
const entry = renderEntries[item.index];
|
||||
if (!entry) return null;
|
||||
return (
|
||||
<div
|
||||
key={entry.key}
|
||||
data-index={item.index}
|
||||
ref={tanstackVirtualizer.measureElement}
|
||||
data-turn-entry={entry.key}
|
||||
>
|
||||
{renderEntry(entry)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
<MessageListEntry
|
||||
entry={entry}
|
||||
onMessageContentChange={context.onMessageContentChange}
|
||||
getAnimationHandlers={context.getAnimationHandlers}
|
||||
scrollToBottom={context.scrollToBottom}
|
||||
stickyUserHeader={context.stickyUserHeader}
|
||||
sessionIsWorking={false}
|
||||
defaultActivityExpanded={context.defaultActivityExpanded}
|
||||
turnUiStates={context.turnUiStates}
|
||||
onToggleTurnGroup={context.onToggleTurnGroup}
|
||||
chatRenderMode={context.chatRenderMode}
|
||||
shouldAnimateUserMessage={context.shouldAnimateUserMessage}
|
||||
onUserAnimationConsumed={context.onUserAnimationConsumed}
|
||||
activeStreamingMessageId={null}
|
||||
activeStreamingPhase={null}
|
||||
reviewTransferDirection={context.reviewTransferDirection}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
StaticHistoryList.displayName = 'StaticHistoryList';
|
||||
TimelineRow.displayName = 'TimelineRow';
|
||||
|
||||
const timelineKeyExtractor = (item: RenderEntry): string => item.key;
|
||||
|
||||
// Row type drives container reuse. Turn blocks and ungrouped messages have very
|
||||
// different shapes, so keeping them in separate pools avoids re-measuring a
|
||||
// container every time one replaces the other.
|
||||
const timelineItemType = (item: RenderEntry): string => item.kind;
|
||||
|
||||
const renderTimelineItem = ({ item }: { item: RenderEntry }) => <TimelineRow entry={item} />;
|
||||
|
||||
type TimelineListProps = {
|
||||
entries: RenderEntry[];
|
||||
streamingTailKey: string | null;
|
||||
registerList: (list: LegendListRef | null) => void;
|
||||
anchoredEndSpace?: {
|
||||
anchorIndex: number;
|
||||
anchorOffset?: number;
|
||||
onReady?: (info: { anchorIndex: number | undefined; anchorKey: string | undefined; size: number }) => void;
|
||||
onSizeChanged?: (size: number) => void;
|
||||
};
|
||||
composerOverlayHeight: number;
|
||||
onIsAtEndChange: (isAtEnd: boolean) => void;
|
||||
onTimelineDataChange: () => void;
|
||||
listHeader?: React.ReactNode;
|
||||
listFooter?: React.ReactNode;
|
||||
scrollContainerProps?: TimelineScrollContainerProps;
|
||||
rowContext: TimelineRowContextValue;
|
||||
};
|
||||
|
||||
const TimelineList = React.memo(({
|
||||
entries,
|
||||
registerList,
|
||||
anchoredEndSpace,
|
||||
composerOverlayHeight,
|
||||
onIsAtEndChange,
|
||||
onTimelineDataChange,
|
||||
listHeader,
|
||||
listFooter,
|
||||
scrollContainerProps,
|
||||
rowContext,
|
||||
}: TimelineListProps) => {
|
||||
const listRef = React.useRef<LegendListRef | null>(null);
|
||||
const isAtEndRef = React.useRef(true);
|
||||
|
||||
const setListRef = React.useCallback((list: LegendListRef | null) => {
|
||||
listRef.current = list;
|
||||
registerList(list);
|
||||
}, [registerList]);
|
||||
|
||||
// The list reports scroll continuously; only end-crossings are interesting,
|
||||
// so the edge is debounced to a state transition here rather than pushing a
|
||||
// callback on every frame.
|
||||
const handleScroll = React.useCallback(() => {
|
||||
const state = listRef.current?.getState();
|
||||
if (!state) return;
|
||||
const isAtEnd = resolveTimelineIsAtEnd(state);
|
||||
if (typeof isAtEnd !== 'boolean' || isAtEnd === isAtEndRef.current) return;
|
||||
isAtEndRef.current = isAtEnd;
|
||||
onIsAtEndChange(isAtEnd);
|
||||
}, [onIsAtEndChange]);
|
||||
|
||||
// Data changes are the only moment an automatic correction can be needed;
|
||||
// the owning hook decides whether one actually applies.
|
||||
React.useEffect(() => {
|
||||
onTimelineDataChange();
|
||||
}, [entries, onTimelineDataChange]);
|
||||
|
||||
const header = React.useMemo(() => (listHeader ? <>{listHeader}</> : undefined), [listHeader]);
|
||||
const footer = React.useMemo(() => (listFooter ? <>{listFooter}</> : undefined), [listFooter]);
|
||||
|
||||
return (
|
||||
<TimelineRowContext.Provider value={rowContext}>
|
||||
<LegendList<RenderEntry>
|
||||
ref={setListRef}
|
||||
data={entries}
|
||||
keyExtractor={timelineKeyExtractor}
|
||||
getItemType={timelineItemType}
|
||||
renderItem={renderTimelineItem}
|
||||
estimatedItemSize={TIMELINE_ESTIMATED_ENTRY_SIZE}
|
||||
initialScrollAtEnd
|
||||
// Chat rows own internal state (expanded tool calls, reveal
|
||||
// animations); recycling a container into a different row would
|
||||
// carry that state across.
|
||||
recycleItems={false}
|
||||
{...(anchoredEndSpace ? { anchoredEndSpace } : {})}
|
||||
contentInsetEndAdjustment={composerOverlayHeight}
|
||||
// While a turn is anchored, the reserved end space — not the
|
||||
// live edge — defines where the viewport rests.
|
||||
maintainScrollAtEnd={anchoredEndSpace
|
||||
? false
|
||||
: { animated: false, on: { dataChange: true, itemLayout: true, layout: true } }}
|
||||
// Prepending older history must not move what the user is
|
||||
// reading. Size restoration stays off: rows growing in place
|
||||
// (a tool result expanding) must grow downward.
|
||||
maintainVisibleContentPosition={{ data: true, size: false }}
|
||||
onScroll={handleScroll}
|
||||
ListHeaderComponent={header}
|
||||
ListFooterComponent={footer}
|
||||
{...scrollContainerProps}
|
||||
/>
|
||||
</TimelineRowContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
TimelineList.displayName = 'TimelineList';
|
||||
|
||||
const StreamingTailContent: React.FC<{
|
||||
entry: RenderEntry;
|
||||
@@ -1262,8 +1164,17 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
onMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
scrollToBottom,
|
||||
scrollRef,
|
||||
directory,
|
||||
registerList,
|
||||
anchorMessageId = null,
|
||||
onAnchorReady,
|
||||
onAnchorSizeChanged,
|
||||
composerOverlayHeight = 0,
|
||||
onIsAtEndChange,
|
||||
onTimelineDataChange,
|
||||
listHeader,
|
||||
listFooter,
|
||||
scrollContainerProps,
|
||||
}, ref) => {
|
||||
streamPerfMark('react.message_list_render');
|
||||
streamPerfCount('ui.message_list.render');
|
||||
@@ -1357,16 +1268,18 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return output;
|
||||
}), [messages]);
|
||||
|
||||
const historyContentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const resolveScrollContainer = React.useCallback((): HTMLDivElement | null => {
|
||||
if (scrollRef?.current) {
|
||||
return scrollRef.current;
|
||||
// The list owns the scroll container. The DOM fallback covers the window
|
||||
// between mount and the list handing us its node.
|
||||
const resolveScrollContainer = React.useCallback((): HTMLElement | null => {
|
||||
const listNode = listRef.current?.getScrollableNode();
|
||||
if (listNode) {
|
||||
return listNode;
|
||||
}
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return document.querySelector<HTMLDivElement>('[data-scrollbar="chat"]');
|
||||
}, [scrollRef]);
|
||||
}, []);
|
||||
|
||||
const displayMessages = React.useMemo(() => streamPerfMeasure('ui.message_list.retry_overlay_ms', () => {
|
||||
return applyRetryOverlay(baseDisplayMessages, {
|
||||
@@ -1483,19 +1396,14 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return { ...entry, nextEntryFirstMessage };
|
||||
});
|
||||
}, [staticRenderEntries, trailingEntryFirstMessage]);
|
||||
// Mobile always starts with the same virtualized engine it will use after
|
||||
// pagination. Switching a short list from normal DOM to TanStack during a
|
||||
// prepend remounts the history subtree, and the newly enabled end-anchored
|
||||
// virtualizer initializes at the bottom before it has prior keyed state.
|
||||
// Desktop keeps the small-list threshold where that transition is not tied
|
||||
// to the explicit mobile load-older interaction.
|
||||
const shouldVirtualizeHistory = isMobileSurfaceRuntime()
|
||||
|| historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||
const historyEngine: HistoryEngine = shouldVirtualizeHistory ? 'tanstack' : 'none';
|
||||
const tanstackVirtualizerRef = React.useRef<TanstackVirtualizerInstance | null>(null);
|
||||
const registerTanstackVirtualizer = React.useCallback((virtualizer: TanstackVirtualizerInstance | null) => {
|
||||
tanstackVirtualizerRef.current = virtualizer;
|
||||
}, []);
|
||||
// Every surface uses the same virtualized list for the whole timeline —
|
||||
// there is no small-list DOM path to transition out of, which is what used
|
||||
// to remount the history subtree mid-prepend.
|
||||
const listRef = React.useRef<LegendListRef | null>(null);
|
||||
const handleRegisterList = React.useCallback((list: LegendListRef | null) => {
|
||||
listRef.current = list;
|
||||
registerList?.(list);
|
||||
}, [registerList]);
|
||||
|
||||
const allEntries = React.useMemo(() => {
|
||||
return trailingStreamingEntry ? [...historyEntries, trailingStreamingEntry] : historyEntries;
|
||||
@@ -1505,8 +1413,14 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
onMessageContentChange(reason);
|
||||
});
|
||||
|
||||
const stableTailContentChange = useStableEvent((reason?: ContentChangeReason) => {
|
||||
onMessageContentChange(reason);
|
||||
// Stable identities: these reach the list, where a changing callback would
|
||||
// re-render every mounted row.
|
||||
const stableIsAtEndChange = useStableEvent((isAtEnd: boolean) => {
|
||||
onIsAtEndChange?.(isAtEnd);
|
||||
});
|
||||
|
||||
const stableTimelineDataChange = useStableEvent(() => {
|
||||
onTimelineDataChange?.();
|
||||
});
|
||||
|
||||
const currentUserOrder = React.useMemo(() => {
|
||||
@@ -1596,22 +1510,18 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!shouldVirtualizeHistory) {
|
||||
const list = listRef.current;
|
||||
if (!list) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const virtualizer = tanstackVirtualizerRef.current;
|
||||
if (!virtualizer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Smooth scrolling can stop at a stale offset while unmounted,
|
||||
// variable-height rows replace estimates with real measurements. Use
|
||||
// exact auto-reconciliation; mounted targets still take the smooth DOM
|
||||
// path below.
|
||||
virtualizer.scrollToIndex(index, { align: 'start', behavior: 'auto' });
|
||||
// Unanimated: an unmounted target's position is still an estimate, and
|
||||
// a smooth scroll would end at that stale offset once the real
|
||||
// measurement replaces it. Mounted targets still take the smooth DOM
|
||||
// path in scrollMessageElementIntoView.
|
||||
void list.scrollToIndex({ index, animated: false, viewPosition: 0 });
|
||||
return true;
|
||||
}, [historyEntries.length, shouldVirtualizeHistory]);
|
||||
}, [historyEntries.length]);
|
||||
|
||||
const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
|
||||
const container = resolveScrollContainer();
|
||||
@@ -1718,7 +1628,9 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
window.requestAnimationFrame(step);
|
||||
},
|
||||
|
||||
isHistoryVirtualized: () => shouldVirtualizeHistory,
|
||||
// The timeline is always virtualized now; the flag stays so callers
|
||||
// that branch on it keep compiling and take the virtualized path.
|
||||
isHistoryVirtualized: () => true,
|
||||
|
||||
captureViewportAnchor: () => {
|
||||
const container = resolveScrollContainer();
|
||||
@@ -1790,14 +1702,15 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
},
|
||||
|
||||
scrollToBottom: () => {
|
||||
if (shouldVirtualizeHistory && historyEntries.length > 0 && tanstackVirtualizerRef.current) {
|
||||
tanstackVirtualizerRef.current.scrollToEnd();
|
||||
const list = listRef.current;
|
||||
if (list) {
|
||||
void list.scrollToEnd({ animated: false });
|
||||
return;
|
||||
}
|
||||
const container = resolveScrollContainer();
|
||||
if (!container) return;
|
||||
// Overshoot so the browser clamps to the exact fractional
|
||||
// maximum (scrollHeight is integer-rounded) — see useChatAutoFollow.
|
||||
// maximum (scrollHeight is integer-rounded).
|
||||
container.scrollTop = container.scrollHeight + 4096;
|
||||
},
|
||||
};
|
||||
@@ -1814,65 +1727,88 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return () => {
|
||||
objectRef.current = null;
|
||||
};
|
||||
}, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, shouldVirtualizeHistory, trailingStreamingEntry, turnIndexMap, ref]);
|
||||
}, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, trailingStreamingEntry, turnIndexMap, ref]);
|
||||
|
||||
const disableFadeIn = false;
|
||||
const anchoredEndSpace = React.useMemo<TimelineAnchoredEndSpace | undefined>(() => {
|
||||
const resolved = resolveChatListAnchoredEndSpace(
|
||||
allEntries,
|
||||
anchorMessageId,
|
||||
(entry) => (entry.kind === 'turn' ? entry.turn.userMessage.info.id : entry.message.info.id),
|
||||
);
|
||||
if (!resolved || !anchorMessageId) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...resolved,
|
||||
onReady: (info) => {
|
||||
if (info.anchorIndex === undefined) return;
|
||||
onAnchorReady?.(anchorMessageId, info.anchorIndex);
|
||||
},
|
||||
onSizeChanged: () => {
|
||||
onAnchorSizeChanged?.(anchorMessageId);
|
||||
},
|
||||
};
|
||||
}, [allEntries, anchorMessageId, onAnchorReady, onAnchorSizeChanged]);
|
||||
|
||||
const rowContext = React.useMemo(() => ({
|
||||
onMessageContentChange: stableHistoryContentChange,
|
||||
getAnimationHandlers: stableGetAnimationHandlers,
|
||||
scrollToBottom: stableScrollToBottom,
|
||||
stickyUserHeader,
|
||||
defaultActivityExpanded,
|
||||
turnUiStates,
|
||||
onToggleTurnGroup: toggleTurnGroup,
|
||||
chatRenderMode,
|
||||
showTurnChangedFiles,
|
||||
shouldAnimateUserMessage,
|
||||
onUserAnimationConsumed,
|
||||
reviewTransferDirection,
|
||||
streamingTailKey: trailingStreamingEntry?.key ?? null,
|
||||
directory,
|
||||
sessionIsWorking,
|
||||
activeStreamingMessageId,
|
||||
activeStreamingPhase,
|
||||
}), [
|
||||
activeStreamingMessageId,
|
||||
activeStreamingPhase,
|
||||
chatRenderMode,
|
||||
defaultActivityExpanded,
|
||||
directory,
|
||||
onUserAnimationConsumed,
|
||||
reviewTransferDirection,
|
||||
sessionIsWorking,
|
||||
shouldAnimateUserMessage,
|
||||
showTurnChangedFiles,
|
||||
stableGetAnimationHandlers,
|
||||
stableHistoryContentChange,
|
||||
stableScrollToBottom,
|
||||
stickyUserHeader,
|
||||
toggleTurnGroup,
|
||||
trailingStreamingEntry?.key,
|
||||
turnUiStates,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FadeInDisabledProvider disabled={disableFadeIn}>
|
||||
<div className="relative w-full">
|
||||
{/* Virtualized history rows unmount/remount during scroll;
|
||||
re-running the reveal fade on every remount reads as
|
||||
blinking. History content is never "new", so fade-in
|
||||
is disabled there — the streaming tail keeps it. */}
|
||||
<FadeInDisabledProvider disabled={shouldVirtualizeHistory}>
|
||||
<StaticHistoryList
|
||||
key={sessionKey}
|
||||
entries={historyEntries}
|
||||
engine={historyEngine}
|
||||
contentRef={historyContentRef}
|
||||
scrollRef={scrollRef}
|
||||
registerTanstackVirtualizer={registerTanstackVirtualizer}
|
||||
virtualizerKey={sessionKey}
|
||||
onMessageContentChange={stableHistoryContentChange}
|
||||
getAnimationHandlers={stableGetAnimationHandlers}
|
||||
scrollToBottom={stableScrollToBottom}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
defaultActivityExpanded={defaultActivityExpanded}
|
||||
turnUiStates={turnUiStates}
|
||||
onToggleTurnGroup={toggleTurnGroup}
|
||||
chatRenderMode={chatRenderMode}
|
||||
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
||||
onUserAnimationConsumed={onUserAnimationConsumed}
|
||||
reviewTransferDirection={reviewTransferDirection}
|
||||
/>
|
||||
</FadeInDisabledProvider>
|
||||
{trailingStreamingEntry ? (
|
||||
<StreamingTailContent
|
||||
entry={trailingStreamingEntry}
|
||||
directory={directory}
|
||||
onMessageContentChange={stableTailContentChange}
|
||||
getAnimationHandlers={stableGetAnimationHandlers}
|
||||
scrollToBottom={stableScrollToBottom}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
defaultActivityExpanded={defaultActivityExpanded}
|
||||
turnUiStates={turnUiStates}
|
||||
onToggleTurnGroup={toggleTurnGroup}
|
||||
chatRenderMode={chatRenderMode}
|
||||
showTurnChangedFiles={showTurnChangedFiles}
|
||||
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
||||
onUserAnimationConsumed={onUserAnimationConsumed}
|
||||
activeStreamingMessageId={activeStreamingMessageId}
|
||||
activeStreamingPhase={activeStreamingPhase}
|
||||
reviewTransferDirection={reviewTransferDirection}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</FadeInDisabledProvider>
|
||||
|
||||
</div>
|
||||
// Virtualized rows unmount/remount during scroll; re-running the reveal
|
||||
// fade on every remount reads as blinking. Rows are never "new" from the
|
||||
// list's point of view, so fade-in is disabled for them — content
|
||||
// arriving inside the streaming tail keeps its own animations.
|
||||
<FadeInDisabledProvider disabled>
|
||||
<TimelineList
|
||||
key={sessionKey}
|
||||
entries={allEntries}
|
||||
streamingTailKey={trailingStreamingEntry?.key ?? null}
|
||||
registerList={handleRegisterList}
|
||||
anchoredEndSpace={anchoredEndSpace}
|
||||
composerOverlayHeight={composerOverlayHeight}
|
||||
onIsAtEndChange={stableIsAtEndChange}
|
||||
onTimelineDataChange={stableTimelineDataChange}
|
||||
listHeader={listHeader}
|
||||
listFooter={listFooter}
|
||||
scrollContainerProps={scrollContainerProps}
|
||||
rowContext={rowContext}
|
||||
/>
|
||||
</FadeInDisabledProvider>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import ProgressiveGroup from '../message/parts/ProgressiveGroup';
|
||||
import type { TurnActivityRecord } from '../lib/turns/types';
|
||||
import type { ToolPopupContent } from '../message/types';
|
||||
import type { StreamPhase } from '../message/types';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
|
||||
|
||||
interface DiffStats {
|
||||
additions: number;
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
CHAT_LIST_ANCHOR_OFFSET,
|
||||
getAnchoredTurnMetrics,
|
||||
getRowBottom,
|
||||
resolveChatListAnchoredEndSpace,
|
||||
resolveTimelineIsAtEnd,
|
||||
type TimelineListMeasurementState,
|
||||
} from './timelineScrollAnchoring';
|
||||
|
||||
const buildState = ({
|
||||
positions,
|
||||
sizes,
|
||||
scroll = 0,
|
||||
scrollLength = 700,
|
||||
}: {
|
||||
readonly positions: readonly number[];
|
||||
readonly sizes: readonly number[];
|
||||
readonly scroll?: number;
|
||||
readonly scrollLength?: number;
|
||||
}): TimelineListMeasurementState => ({
|
||||
data: positions.map((_, index) => index),
|
||||
scroll,
|
||||
scrollLength,
|
||||
positionAtIndex: (index) => positions[index],
|
||||
sizeAtIndex: (index) => sizes[index],
|
||||
});
|
||||
|
||||
describe('getRowBottom', () => {
|
||||
test('measures row bottoms from list row position and size', () => {
|
||||
const state = buildState({ positions: [0, 120], sizes: [80, 40] });
|
||||
|
||||
expect(getRowBottom(state, 1)).toBe(160);
|
||||
});
|
||||
|
||||
test('returns null for unmeasured rows', () => {
|
||||
const state = buildState({ positions: [0], sizes: [80] });
|
||||
|
||||
expect(getRowBottom(state, 5)).toBeNull();
|
||||
});
|
||||
|
||||
test('treats a zero-height row as one pixel tall', () => {
|
||||
const state = buildState({ positions: [0, 120], sizes: [120, 0] });
|
||||
|
||||
expect(getRowBottom(state, 1)).toBe(121);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnchoredTurnMetrics', () => {
|
||||
test('returns null for an empty timeline', () => {
|
||||
const state = buildState({ positions: [], sizes: [] });
|
||||
|
||||
expect(getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 0,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
test('treats the active turn as fitting when it fits above the composer', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 300, 460],
|
||||
sizes: [240, 80, 140],
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.turnHeight).toBe(300);
|
||||
expect(metrics?.usableViewportHeight).toBe(564);
|
||||
expect(metrics?.overflowsUsableViewport).toBe(false);
|
||||
expect(metrics?.targetScrollToRevealEnd).toBe(36);
|
||||
expect(metrics?.scrollDeltaToRevealEnd).toBe(36);
|
||||
});
|
||||
|
||||
test('targets the real row end instead of any temporary reserved tail', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 1720, 1880],
|
||||
sizes: [1600, 80, 120],
|
||||
scroll: 1900,
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.lastBottom).toBe(2000);
|
||||
expect(metrics?.targetScrollToRevealEnd).toBe(1436);
|
||||
expect(metrics?.scrollDeltaToRevealEnd).toBe(0);
|
||||
});
|
||||
|
||||
test('reports overflow only for the current anchored turn', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 900, 1180],
|
||||
sizes: [800, 220, 300],
|
||||
scroll: 900,
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.turnHeight).toBe(580);
|
||||
expect(metrics?.usableViewportHeight).toBe(564);
|
||||
expect(metrics?.overflowsUsableViewport).toBe(true);
|
||||
});
|
||||
|
||||
test('returns the minimal positive scroll delta needed to reveal the turn end', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 900, 1180],
|
||||
sizes: [800, 220, 360],
|
||||
scroll: 900,
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.lastBottom).toBe(1540);
|
||||
expect(metrics?.visibleUsableBottom).toBe(1464);
|
||||
expect(metrics?.scrollDeltaToRevealEnd).toBe(76);
|
||||
});
|
||||
|
||||
test('subtracts composer height from usable viewport height', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 300],
|
||||
sizes: [120, 470],
|
||||
scrollLength: 700,
|
||||
});
|
||||
|
||||
const withoutComposer = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 0,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
const withComposer = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 220,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(withoutComposer?.overflowsUsableViewport).toBe(false);
|
||||
expect(withComposer?.overflowsUsableViewport).toBe(true);
|
||||
});
|
||||
|
||||
test('clamps an out-of-range anchor index to the last row', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 300],
|
||||
sizes: [240, 80],
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 99,
|
||||
composerOverlayHeight: 0,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.anchorTop).toBe(300);
|
||||
expect(metrics?.turnHeight).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTimelineIsAtEnd', () => {
|
||||
test('prefers the near-end threshold over the exact content bottom', () => {
|
||||
expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
|
||||
expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false);
|
||||
});
|
||||
|
||||
test('falls back to the exact end when near-end is unavailable', () => {
|
||||
expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
|
||||
});
|
||||
|
||||
test('reports nothing without a state', () => {
|
||||
expect(resolveTimelineIsAtEnd(undefined)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveChatListAnchoredEndSpace', () => {
|
||||
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'a' }];
|
||||
|
||||
test('returns nothing when no anchor is set', () => {
|
||||
expect(resolveChatListAnchoredEndSpace(rows, null, (row) => row.id)).toBe(undefined);
|
||||
});
|
||||
|
||||
test('returns nothing when the anchor is not in the list', () => {
|
||||
expect(resolveChatListAnchoredEndSpace(rows, 'z', (row) => row.id)).toBe(undefined);
|
||||
});
|
||||
|
||||
test('resolves the last occurrence so a resent message anchors to its live row', () => {
|
||||
expect(resolveChatListAnchoredEndSpace(rows, 'a', (row) => row.id)).toEqual({
|
||||
anchorIndex: 2,
|
||||
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
|
||||
});
|
||||
});
|
||||
|
||||
test('honours an explicit anchor offset', () => {
|
||||
expect(resolveChatListAnchoredEndSpace(rows, 'b', (row) => row.id, { anchorOffset: 40 })).toEqual({
|
||||
anchorIndex: 1,
|
||||
anchorOffset: 40,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// Anchored-turn scroll geometry for the chat timeline.
|
||||
//
|
||||
// The timeline has three mutually exclusive scroll modes:
|
||||
//
|
||||
// • `following-end` — stay pinned to the live edge as content grows.
|
||||
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
|
||||
// of the viewport and the reply streams into reserved space below it. The
|
||||
// viewport does NOT move until the turn outgrows the usable viewport.
|
||||
// • `free-scrolling` — the user took over; nothing moves the scroll
|
||||
// position until they opt back in.
|
||||
//
|
||||
// This module is pure geometry: it reads measurements from the virtualized
|
||||
// list and answers "how far, if at all, must we scroll to reveal the end of
|
||||
// the anchored turn". Keeping it free of DOM and React makes the mode machine
|
||||
// testable without a renderer.
|
||||
//
|
||||
// "Usable viewport" is the visible height minus the composer overlay (the
|
||||
// composer floats over the list) minus the anchor offset, so a turn is only
|
||||
// considered overflowing when it genuinely cannot be read.
|
||||
|
||||
export type TimelineScrollMode = 'following-end' | 'anchoring-new-turn' | 'free-scrolling';
|
||||
|
||||
// Distance from the top of the viewport at which an anchored user message
|
||||
// parks. Small enough to read as "at the top", large enough not to collide
|
||||
// with the timeline's top fade.
|
||||
export const CHAT_LIST_ANCHOR_OFFSET = 16;
|
||||
|
||||
export interface TimelineListMeasurementState {
|
||||
readonly data: readonly unknown[];
|
||||
readonly scroll: number;
|
||||
readonly scrollLength: number;
|
||||
readonly positionAtIndex: (index: number) => number | undefined;
|
||||
readonly sizeAtIndex: (index: number) => number | undefined;
|
||||
}
|
||||
|
||||
export interface AnchoredTurnMetrics {
|
||||
readonly anchorTop: number;
|
||||
readonly lastBottom: number;
|
||||
readonly turnHeight: number;
|
||||
readonly usableViewportHeight: number;
|
||||
readonly visibleUsableBottom: number;
|
||||
readonly overflowsUsableViewport: boolean;
|
||||
readonly targetScrollToRevealEnd: number;
|
||||
readonly scrollDeltaToRevealEnd: number;
|
||||
}
|
||||
|
||||
export const getRowBottom = (
|
||||
state: TimelineListMeasurementState,
|
||||
index: number,
|
||||
): number | null => {
|
||||
const top = state.positionAtIndex(index);
|
||||
const height = state.sizeAtIndex(index);
|
||||
if (
|
||||
typeof top !== 'number'
|
||||
|| typeof height !== 'number'
|
||||
|| !Number.isFinite(top)
|
||||
|| !Number.isFinite(height)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Rows measured at zero height would make an anchored turn look empty and
|
||||
// suppress the reveal scroll; treat them as one pixel tall instead.
|
||||
return top + Math.max(1, height);
|
||||
};
|
||||
|
||||
export const getAnchoredTurnMetrics = ({
|
||||
state,
|
||||
anchorIndex,
|
||||
composerOverlayHeight,
|
||||
anchorOffset,
|
||||
}: {
|
||||
readonly state: TimelineListMeasurementState;
|
||||
readonly anchorIndex: number;
|
||||
readonly composerOverlayHeight: number;
|
||||
readonly anchorOffset: number;
|
||||
}): AnchoredTurnMetrics | null => {
|
||||
if (state.data.length === 0) return null;
|
||||
|
||||
const boundedAnchorIndex = Math.max(0, Math.min(anchorIndex, state.data.length - 1));
|
||||
const anchorTop = state.positionAtIndex(boundedAnchorIndex);
|
||||
// The LAST row bottom, not the content length: the reserved anchored end
|
||||
// space lives past it, and targeting that reserved tail would scroll the
|
||||
// real content off the top.
|
||||
const lastBottom = getRowBottom(state, state.data.length - 1);
|
||||
if (typeof anchorTop !== 'number' || !Number.isFinite(anchorTop) || lastBottom === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usableViewportHeight = Math.max(
|
||||
0,
|
||||
state.scrollLength - composerOverlayHeight - anchorOffset,
|
||||
);
|
||||
const turnHeight = Math.max(0, lastBottom - anchorTop);
|
||||
const visibleUsableBottom = state.scroll + usableViewportHeight;
|
||||
const targetScrollToRevealEnd = Math.max(0, lastBottom - usableViewportHeight);
|
||||
// Never negative: revealing the end must not scroll the timeline backwards.
|
||||
const scrollDeltaToRevealEnd = Math.max(0, targetScrollToRevealEnd - state.scroll);
|
||||
|
||||
return {
|
||||
anchorTop,
|
||||
lastBottom,
|
||||
turnHeight,
|
||||
usableViewportHeight,
|
||||
visibleUsableBottom,
|
||||
overflowsUsableViewport: turnHeight > usableViewportHeight,
|
||||
targetScrollToRevealEnd,
|
||||
scrollDeltaToRevealEnd,
|
||||
};
|
||||
};
|
||||
|
||||
// "At the end" for follow purposes is the NEAR-end threshold, not the exact
|
||||
// content bottom: the timeline's footer (status row plus bottom spacer) sits
|
||||
// below the last row, so requiring the exact bottom would drop out of follow —
|
||||
// and pop the scroll-to-bottom pill — while the user is still looking at the
|
||||
// live edge. `isAtEnd` is only the fallback for states that predate the
|
||||
// near-end signal.
|
||||
export const resolveTimelineIsAtEnd = (
|
||||
state: { readonly isNearEnd?: boolean; readonly isAtEnd?: boolean } | undefined,
|
||||
): boolean | undefined => state?.isNearEnd ?? state?.isAtEnd;
|
||||
|
||||
export interface ChatListAnchoredEndSpace {
|
||||
readonly anchorIndex: number;
|
||||
readonly anchorOffset: number;
|
||||
}
|
||||
|
||||
// Finds the anchored row from the BACK of the list: a retried or re-sent
|
||||
// message id can appear more than once, and the live one is always the last.
|
||||
export const resolveChatListAnchoredEndSpace = <Item, AnchorId>(
|
||||
items: readonly Item[],
|
||||
anchorId: AnchorId | null,
|
||||
getAnchorId: (item: Item) => AnchorId | null,
|
||||
options: { readonly anchorOffset?: number } = {},
|
||||
): ChatListAnchoredEndSpace | undefined => {
|
||||
if (anchorId === null) return undefined;
|
||||
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (item !== undefined && getAnchorId(item) === anchorId) {
|
||||
return {
|
||||
anchorIndex: index,
|
||||
anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -19,7 +19,7 @@ import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialo
|
||||
import { ForkSessionDialog, type ForkSessionExecution } from '@/components/session/ForkSessionDialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
|
||||
|
||||
import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import type { StreamPhase, ToolPopupContent } from '../types';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
|
||||
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
||||
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
|
||||
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { ReasoningTimelineBlock } from './ReasoningPart';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils';
|
||||
import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types';
|
||||
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
||||
import type { StreamPhase } from '../types';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
|
||||
import type { ToolPopupContent } from '../types';
|
||||
import ToolPart from './ToolPart';
|
||||
import { MinDurationShineText } from './MinDurationShineText';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { animate, type AnimationPlaybackControls } from 'motion';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { BusyDots } from './BusyDots';
|
||||
|
||||
@@ -20,7 +20,7 @@ import { toast } from '@/components/ui';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
|
||||
import type { ToolPopupContent } from '../types';
|
||||
import { PlainDiffFallback } from './PlainDiffFallback';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
|
||||
Reference in New Issue
Block a user