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:
@@ -167,6 +167,7 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@legendapp/list": "3.2.0",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@opencode-ai/sdk": "1.18.21",
|
"@opencode-ai/sdk": "1.18.21",
|
||||||
"@pierre/diffs": "1.3.0-beta.6",
|
"@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=="],
|
"@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=="],
|
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
|
||||||
|
|
||||||
"@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="],
|
"@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="],
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@legendapp/list": "3.2.0",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@opencode-ai/sdk": "1.18.21",
|
"@opencode-ai/sdk": "1.18.21",
|
||||||
"@pierre/diffs": "1.3.0-beta.6",
|
"@pierre/diffs": "1.3.0-beta.6",
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import { StatusRowContainer } from './StatusRowContainer';
|
|||||||
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
|
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
|
||||||
import ScrollToBottomButton from './components/ScrollToBottomButton';
|
import ScrollToBottomButton from './components/ScrollToBottomButton';
|
||||||
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
|
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
|
||||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
import { useScrollShadow } from '@/components/ui/useScrollShadow';
|
||||||
import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
import { useChatTimelineScroll, type AnimationHandlers, type ContentChangeReason, type TimelineListHandle } from '@/hooks/useChatTimelineScroll';
|
||||||
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
||||||
import { TimelineDialog } from './TimelineDialog';
|
import { TimelineDialog } from './TimelineDialog';
|
||||||
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
|
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
|
||||||
@@ -151,10 +151,16 @@ type ChatViewportProps = {
|
|||||||
currentSessionKey: string;
|
currentSessionKey: string;
|
||||||
isDesktopExpandedInput: boolean;
|
isDesktopExpandedInput: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
stickyUserHeader: boolean;
|
|
||||||
directory?: string;
|
directory?: string;
|
||||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
messageListRef: React.RefObject<MessageListHandle | 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;
|
pendingRevealWork: boolean;
|
||||||
renderedMessages: SessionMessageRecord[];
|
renderedMessages: SessionMessageRecord[];
|
||||||
isLoadingOlder: boolean;
|
isLoadingOlder: boolean;
|
||||||
@@ -169,7 +175,6 @@ type ChatViewportProps = {
|
|||||||
} | null;
|
} | null;
|
||||||
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||||
handleHistoryScroll: () => void;
|
|
||||||
scrollToBottom: () => void;
|
scrollToBottom: () => void;
|
||||||
sessionQuestions: QuestionRequest[];
|
sessionQuestions: QuestionRequest[];
|
||||||
sessionPermissions: PermissionRequest[];
|
sessionPermissions: PermissionRequest[];
|
||||||
@@ -190,10 +195,16 @@ const ChatViewport = React.memo(({
|
|||||||
currentSessionKey,
|
currentSessionKey,
|
||||||
isDesktopExpandedInput,
|
isDesktopExpandedInput,
|
||||||
isMobile,
|
isMobile,
|
||||||
stickyUserHeader,
|
|
||||||
directory,
|
directory,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
messageListRef,
|
messageListRef,
|
||||||
|
registerList,
|
||||||
|
anchorMessageId,
|
||||||
|
onAnchorReady,
|
||||||
|
onAnchorSizeChanged,
|
||||||
|
composerOverlayHeight,
|
||||||
|
onIsAtEndChange,
|
||||||
|
onTimelineDataChange,
|
||||||
pendingRevealWork,
|
pendingRevealWork,
|
||||||
renderedMessages,
|
renderedMessages,
|
||||||
isLoadingOlder,
|
isLoadingOlder,
|
||||||
@@ -203,7 +214,6 @@ const ChatViewport = React.memo(({
|
|||||||
retryOverlay,
|
retryOverlay,
|
||||||
handleMessageContentChange,
|
handleMessageContentChange,
|
||||||
getAnimationHandlers,
|
getAnimationHandlers,
|
||||||
handleHistoryScroll,
|
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
sessionQuestions,
|
sessionQuestions,
|
||||||
sessionPermissions,
|
sessionPermissions,
|
||||||
@@ -315,6 +325,60 @@ const ChatViewport = React.memo(({
|
|||||||
scrollRef.current?.focus({ preventScroll: true });
|
scrollRef.current?.focus({ preventScroll: true });
|
||||||
}, [scrollRef]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -326,71 +390,32 @@ const ChatViewport = React.memo(({
|
|||||||
aria-hidden={isDesktopExpandedInput}
|
aria-hidden={isDesktopExpandedInput}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<ScrollShadow
|
<MessageList
|
||||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
key={currentSessionKey}
|
||||||
ref={scrollRef}
|
ref={messageListRef}
|
||||||
style={CHAT_SCROLL_STYLE}
|
sessionKey={currentSessionId}
|
||||||
observeMutations={false}
|
disableStaging={pendingRevealWork}
|
||||||
hideTopShadow={isMobile && stickyUserHeader}
|
messages={renderedMessages}
|
||||||
tabIndex={0}
|
sessionIsWorking={sessionIsWorking}
|
||||||
onClick={focusScrollContainer}
|
activeStreamingMessageId={streamingMessageId}
|
||||||
onScroll={handleHistoryScroll}
|
activeStreamingPhase={activeStreamingPhase}
|
||||||
data-scroll-shadow="true"
|
retryOverlay={retryOverlay}
|
||||||
data-scrollbar="chat"
|
onMessageContentChange={handleMessageContentChange}
|
||||||
>
|
getAnimationHandlers={getAnimationHandlers}
|
||||||
<div className="relative z-0 min-h-full">
|
isLoadingOlder={isLoadingOlder}
|
||||||
{showLoadOlderButton && (
|
scrollToBottom={scrollToBottom}
|
||||||
<div className="flex justify-center pt-3 pb-1">
|
directory={directory}
|
||||||
<Button
|
registerList={registerList}
|
||||||
variant="secondary"
|
anchorMessageId={anchorMessageId}
|
||||||
size="sm"
|
onAnchorReady={onAnchorReady}
|
||||||
onClick={onLoadOlder}
|
onAnchorSizeChanged={onAnchorSizeChanged}
|
||||||
disabled={isLoadingOlder}
|
composerOverlayHeight={composerOverlayHeight}
|
||||||
>
|
onIsAtEndChange={onIsAtEndChange}
|
||||||
{isLoadingOlder && (
|
onTimelineDataChange={onTimelineDataChange}
|
||||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
listHeader={listHeader}
|
||||||
)}
|
listFooter={listFooter}
|
||||||
{t('chat.history.loadOlder')}
|
scrollContainerProps={scrollContainerProps}
|
||||||
</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>
|
|
||||||
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
|
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
|
||||||
{showPromptNavigator && promptTurnIds.length >= 2 ? (
|
{showPromptNavigator && promptTurnIds.length >= 2 ? (
|
||||||
<PromptNavigatorRail
|
<PromptNavigatorRail
|
||||||
@@ -411,7 +436,6 @@ const ChatViewport = React.memo(({
|
|||||||
&& prev.currentSessionKey === next.currentSessionKey
|
&& prev.currentSessionKey === next.currentSessionKey
|
||||||
&& prev.isDesktopExpandedInput === next.isDesktopExpandedInput
|
&& prev.isDesktopExpandedInput === next.isDesktopExpandedInput
|
||||||
&& prev.isMobile === next.isMobile
|
&& prev.isMobile === next.isMobile
|
||||||
&& prev.stickyUserHeader === next.stickyUserHeader
|
|
||||||
&& prev.directory === next.directory
|
&& prev.directory === next.directory
|
||||||
&& prev.scrollRef === next.scrollRef
|
&& prev.scrollRef === next.scrollRef
|
||||||
&& prev.messageListRef === next.messageListRef
|
&& prev.messageListRef === next.messageListRef
|
||||||
@@ -424,7 +448,6 @@ const ChatViewport = React.memo(({
|
|||||||
&& prev.retryOverlay === next.retryOverlay
|
&& prev.retryOverlay === next.retryOverlay
|
||||||
&& prev.handleMessageContentChange === next.handleMessageContentChange
|
&& prev.handleMessageContentChange === next.handleMessageContentChange
|
||||||
&& prev.getAnimationHandlers === next.getAnimationHandlers
|
&& prev.getAnimationHandlers === next.getAnimationHandlers
|
||||||
&& prev.handleHistoryScroll === next.handleHistoryScroll
|
|
||||||
&& prev.scrollToBottom === next.scrollToBottom
|
&& prev.scrollToBottom === next.scrollToBottom
|
||||||
&& prev.sessionQuestions === next.sessionQuestions
|
&& prev.sessionQuestions === next.sessionQuestions
|
||||||
&& prev.sessionPermissions === next.sessionPermissions
|
&& prev.sessionPermissions === next.sessionPermissions
|
||||||
@@ -891,23 +914,44 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
activeTurnChangeRef.current(turnId);
|
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 {
|
const {
|
||||||
scrollRef,
|
scrollRef,
|
||||||
|
scrollNode,
|
||||||
|
registerList,
|
||||||
|
anchorMessageId,
|
||||||
|
onAnchorReady,
|
||||||
|
onAnchorSizeChanged,
|
||||||
|
onIsAtEndChange,
|
||||||
|
onManualNavigation,
|
||||||
|
onTimelineDataChange,
|
||||||
notifyContentChange: handleMessageContentChange,
|
notifyContentChange: handleMessageContentChange,
|
||||||
getAnimationHandlers,
|
getAnimationHandlers,
|
||||||
goToBottom,
|
goToBottom,
|
||||||
scrollToBottomOnSend,
|
scrollToBottomOnSend,
|
||||||
releaseAutoFollow,
|
|
||||||
restoreSnapshot,
|
restoreSnapshot,
|
||||||
isPinned,
|
isPinned,
|
||||||
isFollowingProgrammatically,
|
isFollowingProgrammatically,
|
||||||
showScrollButton,
|
showScrollButton,
|
||||||
} = useChatAutoFollow({
|
} = useChatTimelineScroll({
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
currentSessionKey,
|
currentSessionKey,
|
||||||
sessionMessageCount,
|
sessionMessageCount,
|
||||||
sessionIsWorking,
|
composerOverlayHeight,
|
||||||
isMobile,
|
lastUserMessageId,
|
||||||
onActiveTurnChange: handleActiveTurnChange,
|
onActiveTurnChange: handleActiveTurnChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -922,10 +966,28 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
messageListRef,
|
messageListRef,
|
||||||
loadMoreMessages,
|
loadMoreMessages,
|
||||||
goToBottom,
|
goToBottom,
|
||||||
releaseAutoFollow,
|
releaseAutoFollow: onManualNavigation,
|
||||||
isPinned,
|
isPinned,
|
||||||
showScrollButton,
|
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(() => {
|
const resumeToLatestInstant = React.useCallback(() => {
|
||||||
goToBottom('instant');
|
goToBottom('instant');
|
||||||
}, [goToBottom]);
|
}, [goToBottom]);
|
||||||
@@ -1084,7 +1146,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
lastScrolledSessionKeyRef.current = currentSessionKey;
|
lastScrolledSessionKeyRef.current = currentSessionKey;
|
||||||
if (hasHashTarget) {
|
if (hasHashTarget) {
|
||||||
// Hash navigation handler will scroll to target; we just release auto-follow.
|
// Hash navigation handler will scroll to target; we just release auto-follow.
|
||||||
releaseAutoFollow();
|
onManualNavigation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1096,7 +1158,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
window.requestAnimationFrame(run);
|
window.requestAnimationFrame(run);
|
||||||
}
|
}
|
||||||
}, [active, currentSessionId, currentSessionKey, releaseAutoFollow, restoreSnapshot]);
|
}, [active, currentSessionId, currentSessionKey, onManualNavigation, restoreSnapshot]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!messagesEnabled || !currentSessionId) return;
|
if (!messagesEnabled || !currentSessionId) return;
|
||||||
@@ -1265,9 +1327,15 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
currentSessionKey={currentSessionKey ?? currentSessionId ?? ''}
|
currentSessionKey={currentSessionKey ?? currentSessionId ?? ''}
|
||||||
isDesktopExpandedInput={isDesktopExpandedInput}
|
isDesktopExpandedInput={isDesktopExpandedInput}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
stickyUserHeader={stickyUserHeader}
|
|
||||||
directory={effectiveSessionDirectory}
|
directory={effectiveSessionDirectory}
|
||||||
scrollRef={scrollRef}
|
scrollRef={scrollRef}
|
||||||
|
registerList={registerList}
|
||||||
|
anchorMessageId={anchorMessageId}
|
||||||
|
onAnchorReady={onAnchorReady}
|
||||||
|
onAnchorSizeChanged={onAnchorSizeChanged}
|
||||||
|
composerOverlayHeight={composerOverlayHeight}
|
||||||
|
onIsAtEndChange={onIsAtEndChange}
|
||||||
|
onTimelineDataChange={onTimelineDataChange}
|
||||||
messageListRef={messageListRef}
|
messageListRef={messageListRef}
|
||||||
pendingRevealWork={timelineController.pendingRevealWork}
|
pendingRevealWork={timelineController.pendingRevealWork}
|
||||||
renderedMessages={timelineController.renderedMessages}
|
renderedMessages={timelineController.renderedMessages}
|
||||||
@@ -1278,7 +1346,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
retryOverlay={retryOverlay}
|
retryOverlay={retryOverlay}
|
||||||
handleMessageContentChange={handleMessageContentChange}
|
handleMessageContentChange={handleMessageContentChange}
|
||||||
getAnimationHandlers={getAnimationHandlers}
|
getAnimationHandlers={getAnimationHandlers}
|
||||||
handleHistoryScroll={timelineController.handleHistoryScroll}
|
|
||||||
scrollToBottom={resumeToLatestInstant}
|
scrollToBottom={resumeToLatestInstant}
|
||||||
sessionQuestions={sessionQuestions}
|
sessionQuestions={sessionQuestions}
|
||||||
sessionPermissions={sessionPermissions}
|
sessionPermissions={sessionPermissions}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useChatSurfaceMode } from './useChatSurfaceMode';
|
import { useChatSurfaceMode } from './useChatSurfaceMode';
|
||||||
|
|
||||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatTimelineScroll';
|
||||||
import MessageBody from './message/MessageBody';
|
import MessageBody from './message/MessageBody';
|
||||||
import type { AgentMentionInfo } from './message/types';
|
import type { AgentMentionInfo } from './message/types';
|
||||||
import type { StreamPhase, ToolPopupContent } from './message/types';
|
import type { StreamPhase, ToolPopupContent } from './message/types';
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Part } from '@opencode-ai/sdk/v2';
|
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 ChatMessage from './ChatMessage';
|
||||||
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
|
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
|
||||||
import TurnItem from './components/TurnItem';
|
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 type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
|
||||||
import { useTurnRecords } from './hooks/useTurnRecords';
|
import { useTurnRecords } from './hooks/useTurnRecords';
|
||||||
import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
|
import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
|
||||||
@@ -20,8 +20,8 @@ import { streamPerfCount, streamPerfMark, streamPerfMeasure } from '@/stores/uti
|
|||||||
import type { StreamPhase } from './message/types';
|
import type { StreamPhase } from './message/types';
|
||||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||||
import { useSessionParts } from '@/sync/sync-context';
|
import { useSessionParts } from '@/sync/sync-context';
|
||||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
|
||||||
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
|
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
|
||||||
|
import { resolveChatListAnchoredEndSpace, resolveTimelineIsAtEnd } from './lib/scroll/timelineScrollAnchoring';
|
||||||
import {
|
import {
|
||||||
USER_SHELL_MARKER,
|
USER_SHELL_MARKER,
|
||||||
isUserShellMarkerMessage,
|
isUserShellMarkerMessage,
|
||||||
@@ -29,96 +29,52 @@ import {
|
|||||||
type ShellBridgeDetails,
|
type ShellBridgeDetails,
|
||||||
} from './lib/shellBridge';
|
} from './lib/shellBridge';
|
||||||
|
|
||||||
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
|
|
||||||
const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = [];
|
const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = [];
|
||||||
const EMPTY_UNGROUPED_MESSAGE_IDS = new Set<string>();
|
const EMPTY_UNGROUPED_MESSAGE_IDS = new Set<string>();
|
||||||
const TIMELINE_CACHE_LIMIT = 16;
|
|
||||||
|
|
||||||
const sameKeys = (a: readonly string[] | undefined, b: readonly string[] | undefined): boolean => {
|
// --- Timeline virtualization (@legendapp/list) -----------------------------
|
||||||
if (a === b) return true;
|
// The timeline is a single virtualized list on every surface: history turns
|
||||||
if (!a || !b) return false;
|
// AND the live streaming tail are rows of the same list, so the list owns one
|
||||||
if (a.length !== b.length) return false;
|
// coherent scroll position instead of arbitrating between a virtualizer and a
|
||||||
return a.every((key, index) => key === b[index]);
|
// 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) ----------------------
|
// Anchor hold for an explicit viewport restore (session re-entry): row
|
||||||
// The history list virtualizes with @tanstack/react-virtual on all surfaces:
|
// measurements settle over several frames, so a single restore can be
|
||||||
// its core has bottom anchoring (anchorTo: 'end'), key-stable prepend
|
// invalidated by the next measurement pass. Re-assert until it holds still for
|
||||||
// preservation, and native iOS touch/momentum deferral for scroll
|
// STABLE_FRAMES consecutive frames, giving up at MAX_FRAMES.
|
||||||
// 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.
|
|
||||||
const ANCHOR_HOLD_STABLE_FRAMES = 30;
|
const ANCHOR_HOLD_STABLE_FRAMES = 30;
|
||||||
const ANCHOR_HOLD_MAX_FRAMES = 180;
|
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
|
// Reserved tail space that parks an anchored row near the top of the viewport.
|
||||||
// active, iOS owns the scroll position and ANY geometry change above the
|
// `onReady` fires once the list has measured the anchor, `onSizeChanged` when
|
||||||
// viewport races against the native animation — a race that compensation
|
// the reserved size is recomputed.
|
||||||
// logic can only lose sometimes. So freshly loaded older history is held
|
// Presentation-only props forwarded to the scroll container the list renders.
|
||||||
// (data already fetched, store already updated) and inserted into the
|
// Deliberately narrow: the list owns scroll and layout callbacks on that
|
||||||
// rendered list only once the gesture goes quiet. Safety valves: flush when
|
// element, so only styling, focus and click-through are caller-controlled.
|
||||||
// the user gets close to the top (a blank top is worse than a small hop) or
|
type TimelineScrollContainerProps = {
|
||||||
// after MAX_HOLD_MS.
|
className?: string;
|
||||||
const HISTORY_PREPEND_QUIET_MS = 160;
|
style?: React.CSSProperties;
|
||||||
const HISTORY_PREPEND_MAX_HOLD_MS = 1500;
|
tabIndex?: number;
|
||||||
const HISTORY_PREPEND_NEAR_TOP_VIEWPORTS = 1.5;
|
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||||
const HISTORY_PREPEND_MONITOR_INTERVAL_MS = 90;
|
'data-scrollbar'?: string;
|
||||||
|
'data-scroll-shadow'?: string;
|
||||||
// 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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const tanstackTimelineCache = new Map<string, { keys: readonly string[]; items: VirtualItem[] }>();
|
type TimelineAnchoredEndSpace = {
|
||||||
|
anchorIndex: number;
|
||||||
const readTanstackTimelineCache = (sessionKey: string, keys: readonly string[]): VirtualItem[] | undefined => {
|
anchorOffset?: number;
|
||||||
const entry = tanstackTimelineCache.get(sessionKey);
|
onReady?: (info: { anchorIndex: number | undefined; anchorKey: string | undefined; size: number }) => void;
|
||||||
if (!entry) return undefined;
|
onSizeChanged?: (size: number) => void;
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const useStableEvent = <TArgs extends unknown[], TResult>(handler: (...args: TArgs) => TResult) => {
|
const useStableEvent = <TArgs extends unknown[], TResult>(handler: (...args: TArgs) => TResult) => {
|
||||||
@@ -365,8 +321,24 @@ interface MessageListProps {
|
|||||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||||
isLoadingOlder: boolean;
|
isLoadingOlder: boolean;
|
||||||
scrollToBottom?: () => void;
|
scrollToBottom?: () => void;
|
||||||
scrollRef?: React.RefObject<HTMLDivElement | null>;
|
|
||||||
directory?: string;
|
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 {
|
export interface MessageListHandle {
|
||||||
@@ -929,14 +901,10 @@ const MessageListEntry = React.memo(({
|
|||||||
|
|
||||||
MessageListEntry.displayName = 'MessageListEntry';
|
MessageListEntry.displayName = 'MessageListEntry';
|
||||||
|
|
||||||
// Inner component that renders staged turn entries.
|
// Shared row state. Passed through context rather than closed over by
|
||||||
type StaticHistoryListProps = {
|
// `renderItem` so the render callback keeps a stable identity — a changing
|
||||||
entries: RenderEntry[];
|
// `renderItem` makes the list re-render every mounted row on every commit.
|
||||||
engine: HistoryEngine;
|
type TimelineRowContextValue = {
|
||||||
contentRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
scrollRef?: React.RefObject<HTMLDivElement | null>;
|
|
||||||
registerTanstackVirtualizer?: (virtualizer: TanstackVirtualizerInstance | null) => void;
|
|
||||||
virtualizerKey: string;
|
|
||||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||||
scrollToBottom?: () => void;
|
scrollToBottom?: () => void;
|
||||||
@@ -945,242 +913,176 @@ type StaticHistoryListProps = {
|
|||||||
turnUiStates: Map<string, TurnUiState>;
|
turnUiStates: Map<string, TurnUiState>;
|
||||||
onToggleTurnGroup: (turnId: string) => void;
|
onToggleTurnGroup: (turnId: string) => void;
|
||||||
chatRenderMode: 'sorted' | 'live';
|
chatRenderMode: 'sorted' | 'live';
|
||||||
|
showTurnChangedFiles: boolean;
|
||||||
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
|
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
|
||||||
onUserAnimationConsumed: (messageId: string) => void;
|
onUserAnimationConsumed: (messageId: string) => void;
|
||||||
reviewTransferDirection?: ReviewTransferDirection | null;
|
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 TimelineRowContext = React.createContext<TimelineRowContextValue | null>(null);
|
||||||
const isTanstack = engine === 'tanstack';
|
|
||||||
|
|
||||||
// --- Quiet-window prepend (mobile) --------------------------------------
|
const TimelineRow = React.memo(({ entry }: { entry: RenderEntry }) => {
|
||||||
// Gesture tracking for the deferred-prepend decision. Refs only: reading
|
const context = React.useContext(TimelineRowContext);
|
||||||
// them never re-renders, and the render-phase reconcile below needs them.
|
if (!context) return null;
|
||||||
const touchActiveRef = React.useRef(false);
|
|
||||||
const lastScrollAtRef = React.useRef(0);
|
|
||||||
const holdSinceRef = React.useRef<number | null>(null);
|
|
||||||
const deferPrepends = isTanstack && isMobileSurfaceRuntime();
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
if (context.streamingTailKey === entry.key) {
|
||||||
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) => {
|
|
||||||
return (
|
return (
|
||||||
<MessageListEntry
|
<StreamingTailContent
|
||||||
key={entry.key}
|
|
||||||
entry={entry}
|
entry={entry}
|
||||||
onMessageContentChange={onMessageContentChange}
|
directory={context.directory}
|
||||||
getAnimationHandlers={getAnimationHandlers}
|
onMessageContentChange={context.onMessageContentChange}
|
||||||
scrollToBottom={scrollToBottom}
|
getAnimationHandlers={context.getAnimationHandlers}
|
||||||
stickyUserHeader={stickyUserHeader}
|
scrollToBottom={context.scrollToBottom}
|
||||||
sessionIsWorking={false}
|
stickyUserHeader={context.stickyUserHeader}
|
||||||
defaultActivityExpanded={defaultActivityExpanded}
|
sessionIsWorking={context.sessionIsWorking}
|
||||||
turnUiStates={turnUiStates}
|
defaultActivityExpanded={context.defaultActivityExpanded}
|
||||||
onToggleTurnGroup={onToggleTurnGroup}
|
turnUiStates={context.turnUiStates}
|
||||||
chatRenderMode={chatRenderMode}
|
onToggleTurnGroup={context.onToggleTurnGroup}
|
||||||
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
chatRenderMode={context.chatRenderMode}
|
||||||
onUserAnimationConsumed={onUserAnimationConsumed}
|
showTurnChangedFiles={context.showTurnChangedFiles}
|
||||||
activeStreamingMessageId={null}
|
shouldAnimateUserMessage={context.shouldAnimateUserMessage}
|
||||||
activeStreamingPhase={null}
|
onUserAnimationConsumed={context.onUserAnimationConsumed}
|
||||||
reviewTransferDirection={reviewTransferDirection}
|
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') {
|
return (
|
||||||
const virtualItems = tanstackVirtualizer.getVirtualItems();
|
<MessageListEntry
|
||||||
const startOffset = virtualItems[0]?.start ?? 0;
|
entry={entry}
|
||||||
// Rendered rows stay in normal flow inside a single offset wrapper (not
|
onMessageContentChange={context.onMessageContentChange}
|
||||||
// per-row absolute positioning) so per-turn sticky user headers keep
|
getAnimationHandlers={context.getAnimationHandlers}
|
||||||
// working against the scroll container. The offset MUST be padding, not
|
scrollToBottom={context.scrollToBottom}
|
||||||
// transform: a transformed ancestor becomes the sticky containing block,
|
stickyUserHeader={context.stickyUserHeader}
|
||||||
// so headers would stick to the wrapper's (arbitrary, overscan-dependent)
|
sessionIsWorking={false}
|
||||||
// top edge mid-list and float over the previous turn. Padding only
|
defaultActivityExpanded={context.defaultActivityExpanded}
|
||||||
// changes when the virtual window shifts — not per scroll frame — so the
|
turnUiStates={context.turnUiStates}
|
||||||
// layout cost is negligible.
|
onToggleTurnGroup={context.onToggleTurnGroup}
|
||||||
return (
|
chatRenderMode={context.chatRenderMode}
|
||||||
<div ref={sizeContainerRef} className="relative w-full" style={{ height: tanstackVirtualizer.getTotalSize() }}>
|
shouldAnimateUserMessage={context.shouldAnimateUserMessage}
|
||||||
<div style={{ paddingTop: `${startOffset}px` }}>
|
onUserAnimationConsumed={context.onUserAnimationConsumed}
|
||||||
{virtualItems.map((item) => {
|
activeStreamingMessageId={null}
|
||||||
const entry = renderEntries[item.index];
|
activeStreamingPhase={null}
|
||||||
if (!entry) return null;
|
reviewTransferDirection={context.reviewTransferDirection}
|
||||||
return (
|
/>
|
||||||
<div
|
);
|
||||||
key={entry.key}
|
|
||||||
data-index={item.index}
|
|
||||||
ref={tanstackVirtualizer.measureElement}
|
|
||||||
data-turn-entry={entry.key}
|
|
||||||
>
|
|
||||||
{renderEntry(entry)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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<{
|
const StreamingTailContent: React.FC<{
|
||||||
entry: RenderEntry;
|
entry: RenderEntry;
|
||||||
@@ -1262,8 +1164,17 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
onMessageContentChange,
|
onMessageContentChange,
|
||||||
getAnimationHandlers,
|
getAnimationHandlers,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
scrollRef,
|
|
||||||
directory,
|
directory,
|
||||||
|
registerList,
|
||||||
|
anchorMessageId = null,
|
||||||
|
onAnchorReady,
|
||||||
|
onAnchorSizeChanged,
|
||||||
|
composerOverlayHeight = 0,
|
||||||
|
onIsAtEndChange,
|
||||||
|
onTimelineDataChange,
|
||||||
|
listHeader,
|
||||||
|
listFooter,
|
||||||
|
scrollContainerProps,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
streamPerfMark('react.message_list_render');
|
streamPerfMark('react.message_list_render');
|
||||||
streamPerfCount('ui.message_list.render');
|
streamPerfCount('ui.message_list.render');
|
||||||
@@ -1357,16 +1268,18 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
return output;
|
return output;
|
||||||
}), [messages]);
|
}), [messages]);
|
||||||
|
|
||||||
const historyContentRef = React.useRef<HTMLDivElement | null>(null);
|
// The list owns the scroll container. The DOM fallback covers the window
|
||||||
const resolveScrollContainer = React.useCallback((): HTMLDivElement | null => {
|
// between mount and the list handing us its node.
|
||||||
if (scrollRef?.current) {
|
const resolveScrollContainer = React.useCallback((): HTMLElement | null => {
|
||||||
return scrollRef.current;
|
const listNode = listRef.current?.getScrollableNode();
|
||||||
|
if (listNode) {
|
||||||
|
return listNode;
|
||||||
}
|
}
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return document.querySelector<HTMLDivElement>('[data-scrollbar="chat"]');
|
return document.querySelector<HTMLDivElement>('[data-scrollbar="chat"]');
|
||||||
}, [scrollRef]);
|
}, []);
|
||||||
|
|
||||||
const displayMessages = React.useMemo(() => streamPerfMeasure('ui.message_list.retry_overlay_ms', () => {
|
const displayMessages = React.useMemo(() => streamPerfMeasure('ui.message_list.retry_overlay_ms', () => {
|
||||||
return applyRetryOverlay(baseDisplayMessages, {
|
return applyRetryOverlay(baseDisplayMessages, {
|
||||||
@@ -1483,19 +1396,14 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
return { ...entry, nextEntryFirstMessage };
|
return { ...entry, nextEntryFirstMessage };
|
||||||
});
|
});
|
||||||
}, [staticRenderEntries, trailingEntryFirstMessage]);
|
}, [staticRenderEntries, trailingEntryFirstMessage]);
|
||||||
// Mobile always starts with the same virtualized engine it will use after
|
// Every surface uses the same virtualized list for the whole timeline —
|
||||||
// pagination. Switching a short list from normal DOM to TanStack during a
|
// there is no small-list DOM path to transition out of, which is what used
|
||||||
// prepend remounts the history subtree, and the newly enabled end-anchored
|
// to remount the history subtree mid-prepend.
|
||||||
// virtualizer initializes at the bottom before it has prior keyed state.
|
const listRef = React.useRef<LegendListRef | null>(null);
|
||||||
// Desktop keeps the small-list threshold where that transition is not tied
|
const handleRegisterList = React.useCallback((list: LegendListRef | null) => {
|
||||||
// to the explicit mobile load-older interaction.
|
listRef.current = list;
|
||||||
const shouldVirtualizeHistory = isMobileSurfaceRuntime()
|
registerList?.(list);
|
||||||
|| historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
|
}, [registerList]);
|
||||||
const historyEngine: HistoryEngine = shouldVirtualizeHistory ? 'tanstack' : 'none';
|
|
||||||
const tanstackVirtualizerRef = React.useRef<TanstackVirtualizerInstance | null>(null);
|
|
||||||
const registerTanstackVirtualizer = React.useCallback((virtualizer: TanstackVirtualizerInstance | null) => {
|
|
||||||
tanstackVirtualizerRef.current = virtualizer;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const allEntries = React.useMemo(() => {
|
const allEntries = React.useMemo(() => {
|
||||||
return trailingStreamingEntry ? [...historyEntries, trailingStreamingEntry] : historyEntries;
|
return trailingStreamingEntry ? [...historyEntries, trailingStreamingEntry] : historyEntries;
|
||||||
@@ -1505,8 +1413,14 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
onMessageContentChange(reason);
|
onMessageContentChange(reason);
|
||||||
});
|
});
|
||||||
|
|
||||||
const stableTailContentChange = useStableEvent((reason?: ContentChangeReason) => {
|
// Stable identities: these reach the list, where a changing callback would
|
||||||
onMessageContentChange(reason);
|
// re-render every mounted row.
|
||||||
|
const stableIsAtEndChange = useStableEvent((isAtEnd: boolean) => {
|
||||||
|
onIsAtEndChange?.(isAtEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
const stableTimelineDataChange = useStableEvent(() => {
|
||||||
|
onTimelineDataChange?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentUserOrder = React.useMemo(() => {
|
const currentUserOrder = React.useMemo(() => {
|
||||||
@@ -1596,22 +1510,18 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldVirtualizeHistory) {
|
const list = listRef.current;
|
||||||
|
if (!list) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const virtualizer = tanstackVirtualizerRef.current;
|
// Unanimated: an unmounted target's position is still an estimate, and
|
||||||
if (!virtualizer) {
|
// a smooth scroll would end at that stale offset once the real
|
||||||
return false;
|
// measurement replaces it. Mounted targets still take the smooth DOM
|
||||||
}
|
// path in scrollMessageElementIntoView.
|
||||||
|
void list.scrollToIndex({ index, animated: false, viewPosition: 0 });
|
||||||
// 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' });
|
|
||||||
return true;
|
return true;
|
||||||
}, [historyEntries.length, shouldVirtualizeHistory]);
|
}, [historyEntries.length]);
|
||||||
|
|
||||||
const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
|
const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
|
||||||
const container = resolveScrollContainer();
|
const container = resolveScrollContainer();
|
||||||
@@ -1718,7 +1628,9 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
window.requestAnimationFrame(step);
|
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: () => {
|
captureViewportAnchor: () => {
|
||||||
const container = resolveScrollContainer();
|
const container = resolveScrollContainer();
|
||||||
@@ -1790,14 +1702,15 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
},
|
},
|
||||||
|
|
||||||
scrollToBottom: () => {
|
scrollToBottom: () => {
|
||||||
if (shouldVirtualizeHistory && historyEntries.length > 0 && tanstackVirtualizerRef.current) {
|
const list = listRef.current;
|
||||||
tanstackVirtualizerRef.current.scrollToEnd();
|
if (list) {
|
||||||
|
void list.scrollToEnd({ animated: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const container = resolveScrollContainer();
|
const container = resolveScrollContainer();
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
// Overshoot so the browser clamps to the exact fractional
|
// 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;
|
container.scrollTop = container.scrollHeight + 4096;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1814,65 +1727,88 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
return () => {
|
return () => {
|
||||||
objectRef.current = null;
|
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 (
|
return (
|
||||||
<div>
|
// Virtualized rows unmount/remount during scroll; re-running the reveal
|
||||||
<FadeInDisabledProvider disabled={disableFadeIn}>
|
// fade on every remount reads as blinking. Rows are never "new" from the
|
||||||
<div className="relative w-full">
|
// list's point of view, so fade-in is disabled for them — content
|
||||||
{/* Virtualized history rows unmount/remount during scroll;
|
// arriving inside the streaming tail keeps its own animations.
|
||||||
re-running the reveal fade on every remount reads as
|
<FadeInDisabledProvider disabled>
|
||||||
blinking. History content is never "new", so fade-in
|
<TimelineList
|
||||||
is disabled there — the streaming tail keeps it. */}
|
key={sessionKey}
|
||||||
<FadeInDisabledProvider disabled={shouldVirtualizeHistory}>
|
entries={allEntries}
|
||||||
<StaticHistoryList
|
streamingTailKey={trailingStreamingEntry?.key ?? null}
|
||||||
key={sessionKey}
|
registerList={handleRegisterList}
|
||||||
entries={historyEntries}
|
anchoredEndSpace={anchoredEndSpace}
|
||||||
engine={historyEngine}
|
composerOverlayHeight={composerOverlayHeight}
|
||||||
contentRef={historyContentRef}
|
onIsAtEndChange={stableIsAtEndChange}
|
||||||
scrollRef={scrollRef}
|
onTimelineDataChange={stableTimelineDataChange}
|
||||||
registerTanstackVirtualizer={registerTanstackVirtualizer}
|
listHeader={listHeader}
|
||||||
virtualizerKey={sessionKey}
|
listFooter={listFooter}
|
||||||
onMessageContentChange={stableHistoryContentChange}
|
scrollContainerProps={scrollContainerProps}
|
||||||
getAnimationHandlers={stableGetAnimationHandlers}
|
rowContext={rowContext}
|
||||||
scrollToBottom={stableScrollToBottom}
|
/>
|
||||||
stickyUserHeader={stickyUserHeader}
|
</FadeInDisabledProvider>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import ProgressiveGroup from '../message/parts/ProgressiveGroup';
|
|||||||
import type { TurnActivityRecord } from '../lib/turns/types';
|
import type { TurnActivityRecord } from '../lib/turns/types';
|
||||||
import type { ToolPopupContent } from '../message/types';
|
import type { ToolPopupContent } from '../message/types';
|
||||||
import type { StreamPhase } from '../message/types';
|
import type { StreamPhase } from '../message/types';
|
||||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
|
||||||
|
|
||||||
interface DiffStats {
|
interface DiffStats {
|
||||||
additions: number;
|
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 { ForkSessionDialog, type ForkSessionExecution } from '@/components/session/ForkSessionDialog';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
|
||||||
|
|
||||||
import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import type { Part } from '@opencode-ai/sdk/v2';
|
import type { Part } from '@opencode-ai/sdk/v2';
|
||||||
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
||||||
import type { StreamPhase, ToolPopupContent } from '../types';
|
import type { StreamPhase, ToolPopupContent } from '../types';
|
||||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
|
||||||
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
||||||
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
|
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
|
||||||
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Part } from '@opencode-ai/sdk/v2';
|
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 { useUIStore } from '@/stores/useUIStore';
|
||||||
import { ReasoningTimelineBlock } from './ReasoningPart';
|
import { ReasoningTimelineBlock } from './ReasoningPart';
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types';
|
import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types';
|
||||||
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
||||||
import type { StreamPhase } from '../types';
|
import type { StreamPhase } from '../types';
|
||||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
|
||||||
import type { ToolPopupContent } from '../types';
|
import type { ToolPopupContent } from '../types';
|
||||||
import ToolPart from './ToolPart';
|
import ToolPart from './ToolPart';
|
||||||
import { MinDurationShineText } from './MinDurationShineText';
|
import { MinDurationShineText } from './MinDurationShineText';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { animate, type AnimationPlaybackControls } from 'motion';
|
import { animate, type AnimationPlaybackControls } from 'motion';
|
||||||
import type { Part } from '@opencode-ai/sdk/v2';
|
import type { Part } from '@opencode-ai/sdk/v2';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { Icon } from '@/components/icon/Icon';
|
import { Icon } from '@/components/icon/Icon';
|
||||||
import { BusyDots } from './BusyDots';
|
import { BusyDots } from './BusyDots';
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { toast } from '@/components/ui';
|
|||||||
import { Text } from '@/components/ui/text';
|
import { Text } from '@/components/ui/text';
|
||||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
import type { ContentChangeReason } from '@/hooks/useChatTimelineScroll';
|
||||||
import type { ToolPopupContent } from '../types';
|
import type { ToolPopupContent } from '../types';
|
||||||
import { PlainDiffFallback } from './PlainDiffFallback';
|
import { PlainDiffFallback } from './PlainDiffFallback';
|
||||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
import { useScrollShadow, type ScrollShadowOrientation, type ScrollShadowVisibility } from "./useScrollShadow";
|
||||||
|
|
||||||
export type ScrollShadowProps = React.HTMLAttributes<HTMLElement> & {
|
export type ScrollShadowProps = React.HTMLAttributes<HTMLElement> & {
|
||||||
as?: React.ElementType;
|
as?: React.ElementType;
|
||||||
orientation?: "vertical" | "horizontal";
|
orientation?: ScrollShadowOrientation;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
isEnabled?: boolean;
|
isEnabled?: boolean;
|
||||||
hideTopShadow?: boolean;
|
hideTopShadow?: boolean;
|
||||||
hideBottomShadow?: boolean;
|
hideBottomShadow?: boolean;
|
||||||
observeMutations?: boolean;
|
observeMutations?: boolean;
|
||||||
onVisibilityChange?: (state: "both" | "none" | "top" | "bottom" | "left" | "right") => void;
|
onVisibilityChange?: (state: ScrollShadowVisibility) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function mergeRefs<T>(...refs: Array<React.Ref<T>>): React.RefCallback<T> {
|
function mergeRefs<T>(...refs: Array<React.Ref<T>>): React.RefCallback<T> {
|
||||||
@@ -44,7 +46,6 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
|
|||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const internalRef = React.useRef<HTMLElement>(null);
|
const internalRef = React.useRef<HTMLElement>(null);
|
||||||
const visibleRef = React.useRef<"both" | "none" | "top" | "bottom" | "left" | "right">("none");
|
|
||||||
|
|
||||||
const dataScrollShadow = (rest as Record<string, unknown>)["data-scroll-shadow"];
|
const dataScrollShadow = (rest as Record<string, unknown>)["data-scroll-shadow"];
|
||||||
delete (rest as Record<string, unknown>)["data-scroll-shadow"];
|
delete (rest as Record<string, unknown>)["data-scroll-shadow"];
|
||||||
@@ -57,104 +58,15 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
|
|||||||
return next;
|
return next;
|
||||||
}, [size, style]);
|
}, [size, style]);
|
||||||
|
|
||||||
const setAttributes = React.useCallback(
|
useScrollShadow(internalRef, {
|
||||||
(el: HTMLElement, hasBefore: boolean, hasAfter: boolean, prefix: "top" | "left", suffix: "bottom" | "right") => {
|
orientation,
|
||||||
const bothKey = `${prefix}${suffix.charAt(0).toUpperCase()}${suffix.slice(1)}Scroll` as const;
|
offset,
|
||||||
|
isEnabled,
|
||||||
if (hasBefore && hasAfter) {
|
hideTopShadow,
|
||||||
(el.dataset as Record<string, string>)[bothKey] = "true";
|
hideBottomShadow,
|
||||||
el.removeAttribute(`data-${prefix}-scroll`);
|
observeMutations,
|
||||||
el.removeAttribute(`data-${suffix}-scroll`);
|
onVisibilityChange,
|
||||||
} else {
|
});
|
||||||
el.dataset[`${prefix}Scroll`] = String(hasBefore);
|
|
||||||
el.dataset[`${suffix}Scroll`] = String(hasAfter);
|
|
||||||
el.removeAttribute(`data-${prefix}-${suffix}-scroll`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearAttributes = React.useCallback((el: HTMLElement) => {
|
|
||||||
["top", "bottom", "top-bottom", "left", "right", "left-right"].forEach((attr) => {
|
|
||||||
el.removeAttribute(`data-${attr}-scroll`);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const checkOverflow = React.useCallback(() => {
|
|
||||||
const el = internalRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
if (!isEnabled) {
|
|
||||||
clearAttributes(el);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subpixel tolerance: on hi-DPI (Retina) and with fractional scrollTop,
|
|
||||||
// scrollTop+clientHeight can fall ~0.5px short of scrollHeight at the very end,
|
|
||||||
// which would otherwise keep the bottom fade visible after fully scrolling.
|
|
||||||
const SUBPIXEL_TOLERANCE = 1;
|
|
||||||
const hasBefore =
|
|
||||||
orientation === "vertical"
|
|
||||||
? el.scrollTop > offset + SUBPIXEL_TOLERANCE
|
|
||||||
: el.scrollLeft > offset + SUBPIXEL_TOLERANCE;
|
|
||||||
let hasAfter =
|
|
||||||
orientation === "vertical"
|
|
||||||
? el.scrollHeight - (el.scrollTop + el.clientHeight) > offset + SUBPIXEL_TOLERANCE
|
|
||||||
: el.scrollWidth - (el.scrollLeft + el.clientWidth) > offset + SUBPIXEL_TOLERANCE;
|
|
||||||
|
|
||||||
const effectiveHasBefore = hideTopShadow && orientation === "vertical" ? false : hasBefore;
|
|
||||||
|
|
||||||
if (hideBottomShadow && orientation === "vertical") {
|
|
||||||
hasAfter = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAttributes(el, effectiveHasBefore, hasAfter, orientation === "vertical" ? "top" : "left", orientation === "vertical" ? "bottom" : "right");
|
|
||||||
|
|
||||||
const next = effectiveHasBefore && hasAfter ? "both" : effectiveHasBefore ? (orientation === "vertical" ? "top" : "left") : hasAfter ? (orientation === "vertical" ? "bottom" : "right") : "none";
|
|
||||||
if (next !== visibleRef.current) {
|
|
||||||
visibleRef.current = next;
|
|
||||||
onVisibilityChange?.(next);
|
|
||||||
}
|
|
||||||
}, [clearAttributes, hideTopShadow, hideBottomShadow, isEnabled, offset, onVisibilityChange, orientation, setAttributes]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const el = internalRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
// Throttle with RAF to avoid excessive calls during rapid DOM changes
|
|
||||||
let rafId: number | null = null;
|
|
||||||
const throttledCheck = () => {
|
|
||||||
if (rafId !== null) return;
|
|
||||||
rafId = requestAnimationFrame(() => {
|
|
||||||
rafId = null;
|
|
||||||
checkOverflow();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScroll = () => checkOverflow(); // Scroll should be immediate
|
|
||||||
const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(throttledCheck) : null;
|
|
||||||
const mutationObserver =
|
|
||||||
observeMutations && typeof MutationObserver !== "undefined" ? new MutationObserver(throttledCheck) : null;
|
|
||||||
|
|
||||||
checkOverflow();
|
|
||||||
|
|
||||||
el.addEventListener("scroll", handleScroll, { passive: true });
|
|
||||||
resizeObserver?.observe(el);
|
|
||||||
// checkOverflow mutates our data-scroll attributes; observing attributes
|
|
||||||
// would make the component trigger its own observer indefinitely.
|
|
||||||
mutationObserver?.observe(el, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
characterData: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
||||||
el.removeEventListener("scroll", handleScroll);
|
|
||||||
resizeObserver?.disconnect();
|
|
||||||
mutationObserver?.disconnect();
|
|
||||||
};
|
|
||||||
}, [checkOverflow, observeMutations]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// Scroll-shadow state as data attributes on a scroll container.
|
||||||
|
//
|
||||||
|
// The logic lives in a hook rather than only inside <ScrollShadow> because the
|
||||||
|
// chat timeline's scroll container is owned by the virtualized list component,
|
||||||
|
// which renders its own element — there is no wrapper to hand the styling to.
|
||||||
|
// <ScrollShadow> is a thin wrapper over this hook, so both paths stay in sync.
|
||||||
|
|
||||||
|
export type ScrollShadowOrientation = "vertical" | "horizontal";
|
||||||
|
export type ScrollShadowVisibility = "both" | "none" | "top" | "bottom" | "left" | "right";
|
||||||
|
|
||||||
|
export type UseScrollShadowOptions = {
|
||||||
|
orientation?: ScrollShadowOrientation;
|
||||||
|
offset?: number;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
hideTopShadow?: boolean;
|
||||||
|
hideBottomShadow?: boolean;
|
||||||
|
observeMutations?: boolean;
|
||||||
|
onVisibilityChange?: (state: ScrollShadowVisibility) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCROLL_SHADOW_ATTRIBUTES = [
|
||||||
|
"top",
|
||||||
|
"bottom",
|
||||||
|
"top-bottom",
|
||||||
|
"left",
|
||||||
|
"right",
|
||||||
|
"left-right",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const clearScrollShadowAttributes = (el: HTMLElement): void => {
|
||||||
|
SCROLL_SHADOW_ATTRIBUTES.forEach((attr) => {
|
||||||
|
el.removeAttribute(`data-${attr}-scroll`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setScrollShadowAttributes = (
|
||||||
|
el: HTMLElement,
|
||||||
|
hasBefore: boolean,
|
||||||
|
hasAfter: boolean,
|
||||||
|
prefix: "top" | "left",
|
||||||
|
suffix: "bottom" | "right",
|
||||||
|
): void => {
|
||||||
|
const bothKey = `${prefix}${suffix.charAt(0).toUpperCase()}${suffix.slice(1)}Scroll` as const;
|
||||||
|
|
||||||
|
if (hasBefore && hasAfter) {
|
||||||
|
(el.dataset as Record<string, string>)[bothKey] = "true";
|
||||||
|
el.removeAttribute(`data-${prefix}-scroll`);
|
||||||
|
el.removeAttribute(`data-${suffix}-scroll`);
|
||||||
|
} else {
|
||||||
|
el.dataset[`${prefix}Scroll`] = String(hasBefore);
|
||||||
|
el.dataset[`${suffix}Scroll`] = String(hasAfter);
|
||||||
|
el.removeAttribute(`data-${prefix}-${suffix}-scroll`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useScrollShadow = (
|
||||||
|
elementRef: React.RefObject<HTMLElement | null>,
|
||||||
|
{
|
||||||
|
orientation = "vertical",
|
||||||
|
offset = 0,
|
||||||
|
isEnabled = true,
|
||||||
|
hideTopShadow = false,
|
||||||
|
hideBottomShadow = false,
|
||||||
|
observeMutations = true,
|
||||||
|
onVisibilityChange,
|
||||||
|
}: UseScrollShadowOptions = {},
|
||||||
|
): void => {
|
||||||
|
const visibleRef = React.useRef<ScrollShadowVisibility>("none");
|
||||||
|
|
||||||
|
const checkOverflow = React.useCallback(() => {
|
||||||
|
const el = elementRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (!isEnabled) {
|
||||||
|
clearScrollShadowAttributes(el);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subpixel tolerance: on hi-DPI (Retina) and with fractional scrollTop,
|
||||||
|
// scrollTop+clientHeight can fall ~0.5px short of scrollHeight at the very end,
|
||||||
|
// which would otherwise keep the bottom fade visible after fully scrolling.
|
||||||
|
const SUBPIXEL_TOLERANCE = 1;
|
||||||
|
const hasBefore =
|
||||||
|
orientation === "vertical"
|
||||||
|
? el.scrollTop > offset + SUBPIXEL_TOLERANCE
|
||||||
|
: el.scrollLeft > offset + SUBPIXEL_TOLERANCE;
|
||||||
|
let hasAfter =
|
||||||
|
orientation === "vertical"
|
||||||
|
? el.scrollHeight - (el.scrollTop + el.clientHeight) > offset + SUBPIXEL_TOLERANCE
|
||||||
|
: el.scrollWidth - (el.scrollLeft + el.clientWidth) > offset + SUBPIXEL_TOLERANCE;
|
||||||
|
|
||||||
|
const effectiveHasBefore = hideTopShadow && orientation === "vertical" ? false : hasBefore;
|
||||||
|
|
||||||
|
if (hideBottomShadow && orientation === "vertical") {
|
||||||
|
hasAfter = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setScrollShadowAttributes(
|
||||||
|
el,
|
||||||
|
effectiveHasBefore,
|
||||||
|
hasAfter,
|
||||||
|
orientation === "vertical" ? "top" : "left",
|
||||||
|
orientation === "vertical" ? "bottom" : "right",
|
||||||
|
);
|
||||||
|
|
||||||
|
const next: ScrollShadowVisibility = effectiveHasBefore && hasAfter
|
||||||
|
? "both"
|
||||||
|
: effectiveHasBefore
|
||||||
|
? (orientation === "vertical" ? "top" : "left")
|
||||||
|
: hasAfter
|
||||||
|
? (orientation === "vertical" ? "bottom" : "right")
|
||||||
|
: "none";
|
||||||
|
if (next !== visibleRef.current) {
|
||||||
|
visibleRef.current = next;
|
||||||
|
onVisibilityChange?.(next);
|
||||||
|
}
|
||||||
|
}, [elementRef, hideTopShadow, hideBottomShadow, isEnabled, offset, onVisibilityChange, orientation]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = elementRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// Throttle with RAF to avoid excessive calls during rapid DOM changes
|
||||||
|
let rafId: number | null = null;
|
||||||
|
const throttledCheck = () => {
|
||||||
|
if (rafId !== null) return;
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
rafId = null;
|
||||||
|
checkOverflow();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => checkOverflow(); // Scroll should be immediate
|
||||||
|
const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(throttledCheck) : null;
|
||||||
|
const mutationObserver =
|
||||||
|
observeMutations && typeof MutationObserver !== "undefined" ? new MutationObserver(throttledCheck) : null;
|
||||||
|
|
||||||
|
checkOverflow();
|
||||||
|
|
||||||
|
el.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
resizeObserver?.observe(el);
|
||||||
|
// checkOverflow mutates our data-scroll attributes; observing attributes
|
||||||
|
// would make the hook trigger its own observer indefinitely.
|
||||||
|
mutationObserver?.observe(el, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||||
|
el.removeEventListener("scroll", handleScroll);
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
mutationObserver?.disconnect();
|
||||||
|
};
|
||||||
|
}, [checkOverflow, elementRef, observeMutations]);
|
||||||
|
};
|
||||||
@@ -1,938 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
|
||||||
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
|
|
||||||
import { useViewportStore } from '@/sync/viewport-store';
|
|
||||||
|
|
||||||
type AutoFollowState = 'following' | 'released';
|
|
||||||
|
|
||||||
export type ContentChangeReason = 'text' | 'structural' | 'permission' | 'animation';
|
|
||||||
|
|
||||||
export interface AnimationHandlers {
|
|
||||||
onChunk: () => void;
|
|
||||||
onComplete: () => void;
|
|
||||||
onStreamingCandidate?: () => void;
|
|
||||||
onAnimationStart?: () => void;
|
|
||||||
onReservationCancelled?: () => void;
|
|
||||||
onReasoningBlock?: () => void;
|
|
||||||
onAnimatedHeightChange?: (height: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseChatAutoFollowOptions {
|
|
||||||
currentSessionId: string | null;
|
|
||||||
currentSessionKey: string | null;
|
|
||||||
sessionMessageCount: number;
|
|
||||||
sessionIsWorking: boolean;
|
|
||||||
isMobile: boolean;
|
|
||||||
onActiveTurnChange?: (turnId: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseChatAutoFollowResult {
|
|
||||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
state: AutoFollowState;
|
|
||||||
isPinned: boolean;
|
|
||||||
isOverflowing: boolean;
|
|
||||||
isFollowingProgrammatically: boolean;
|
|
||||||
showScrollButton: boolean;
|
|
||||||
notifyContentChange: (reason?: ContentChangeReason) => void;
|
|
||||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
|
||||||
goToBottom: (mode?: 'instant' | 'smooth') => void;
|
|
||||||
scrollToBottomOnSend: () => void;
|
|
||||||
releaseAutoFollow: () => void;
|
|
||||||
saveSnapshotNow: () => void;
|
|
||||||
restoreSnapshot: () => Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────
|
|
||||||
// Chat auto-follow. The model is deliberately simple, which is what makes it
|
|
||||||
// flicker-free:
|
|
||||||
//
|
|
||||||
// • Auto-follow is on unless the user scrolled up (`released`), AND passive
|
|
||||||
// following only acts while the session is active (working, plus a short
|
|
||||||
// settle window). When idle, content-size changes are layout churn
|
|
||||||
// (virtualizer re-measurement, async tool/code rendering) rather than live
|
|
||||||
// growth, so the hook leaves scroll alone — re-pinning then would fight the
|
|
||||||
// virtualizer and twitch the viewport.
|
|
||||||
// • Following the bottom is INSTANT — `scrollTop = scrollHeight` inside the
|
|
||||||
// content ResizeObserver, which fires after layout and before paint. There
|
|
||||||
// is NO easing loop and NO settle burst, so there are never two writers
|
|
||||||
// racing for `scrollTop` (the root cause of the old jiggle/double-scroll).
|
|
||||||
// • A short-lived "auto" marker (position + 1500ms) lets the scroll handler
|
|
||||||
// distinguish our own programmatic writes from genuine user scrolling, so
|
|
||||||
// a scroll event that lands at our just-written bottom never trips a false
|
|
||||||
// release.
|
|
||||||
//
|
|
||||||
// The public interface below is unchanged from the old implementation so every
|
|
||||||
// consumer (ChatContainer, message parts, the timeline controller) keeps
|
|
||||||
// working without edits.
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const BOTTOM_SPACER_DESKTOP_VH = 0.10;
|
|
||||||
const BOTTOM_SPACER_MOBILE_PX = 40;
|
|
||||||
const SAVE_DEBOUNCE_MS = 150;
|
|
||||||
const TOUCH_FINGER_DOWN_THRESHOLD = 2;
|
|
||||||
// How long an "auto" (programmatic) scroll position stays trusted. Browsers can
|
|
||||||
// dispatch the `scroll` event for our write asynchronously, after newer content
|
|
||||||
// has already changed the geometry; the window keeps us from reading that lag as
|
|
||||||
// a user scroll.
|
|
||||||
const AUTO_MARK_TTL_MS = 1500;
|
|
||||||
const AUTO_MATCH_TOLERANCE_PX = 2;
|
|
||||||
// While a tracked height animation runs (e.g. a Thinking block auto-collapsing
|
|
||||||
// mid-stream), the timeline shrinks/grows over a couple hundred ms and the
|
|
||||||
// virtualizer re-measures, producing transient geometry. Browsers dispatch the
|
|
||||||
// resulting `scroll` events asynchronously, so a stale event can land after we
|
|
||||||
// have already re-pinned — its position matching neither the bottom zone nor the
|
|
||||||
// freshly-moved auto marker — and be misread as a user scroll-away. During this
|
|
||||||
// guard window we treat any `following`-state scroll event as our own and never
|
|
||||||
// release via the heuristic. GENUINE user gestures still release instantly
|
|
||||||
// through releaseFromUserIntent, so this is not glue. Sized to the reasoning
|
|
||||||
// animation (200ms) plus headroom for trailing async scroll events.
|
|
||||||
const ANIMATION_GUARD_MS = 350;
|
|
||||||
// After streaming stops, keep following the bottom for a short window so the
|
|
||||||
// final content can settle into place.
|
|
||||||
const SETTLE_MS = 300;
|
|
||||||
// Entry-stick window. On the FIRST open of a session, late async data (most
|
|
||||||
// visibly a task/subagent tool whose nested rows are fetched from the child
|
|
||||||
// session after entry — see useEnsureSessionMessages in ToolPart.tsx) grows the
|
|
||||||
// timeline a beat or two AFTER we have already pinned to the bottom, leaving the
|
|
||||||
// viewport stranded mid-history. The steady-state idle gate deliberately ignores
|
|
||||||
// that growth (it can't tell entry from a user reading idle history). So instead
|
|
||||||
// of weakening the gate, we open a short, gesture-cancellable window on entry
|
|
||||||
// during which we FORCE the bottom on every growth. It ends QUIESCENCE_MS after
|
|
||||||
// growth stops (capped by MAX_MS), or instantly on any real user scroll gesture.
|
|
||||||
const ENTRY_STICK_QUIESCENCE_MS = 600;
|
|
||||||
const ENTRY_STICK_MAX_MS = 8000;
|
|
||||||
|
|
||||||
const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
||||||
|
|
||||||
// The bottom of the chat has an empty spacer (10vh on desktop, 40px on mobile)
|
|
||||||
// — its height is exactly how far above scrollHeight the user can be while still
|
|
||||||
// looking at "empty" space. We use that same value as the threshold for both
|
|
||||||
// re-pinning auto-follow and showing the scroll-to-bottom button.
|
|
||||||
const computeBottomZoneThreshold = (isMobile: boolean, container?: HTMLElement | null): number => {
|
|
||||||
if (isMobile) return BOTTOM_SPACER_MOBILE_PX;
|
|
||||||
const height = container?.clientHeight ?? 0;
|
|
||||||
if (height <= 0) return 96;
|
|
||||||
return Math.max(48, height * BOTTOM_SPACER_DESKTOP_VH);
|
|
||||||
};
|
|
||||||
|
|
||||||
const distanceFromBottom = (el: HTMLElement): number => {
|
|
||||||
return el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
||||||
};
|
|
||||||
|
|
||||||
const canScroll = (el: HTMLElement): boolean => {
|
|
||||||
return el.scrollHeight - el.clientHeight > 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNearBottom = (el: HTMLElement, isMobile: boolean): boolean => {
|
|
||||||
return distanceFromBottom(el) <= computeBottomZoneThreshold(isMobile, el);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isReleaseKey = (event: KeyboardEvent): boolean => {
|
|
||||||
if (event.altKey || event.ctrlKey || event.metaKey) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
switch (event.key) {
|
|
||||||
case 'ArrowUp':
|
|
||||||
case 'PageUp':
|
|
||||||
case 'Home':
|
|
||||||
return true;
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nestedScrollableTarget = (root: HTMLElement, target: EventTarget | null): HTMLElement | null => {
|
|
||||||
if (!(target instanceof Element)) return null;
|
|
||||||
const nested = target.closest('[data-scrollable]');
|
|
||||||
if (!nested || nested === root || !(nested instanceof HTMLElement)) return null;
|
|
||||||
return nested;
|
|
||||||
};
|
|
||||||
|
|
||||||
const nestedScrollableCanConsumeUp = (root: HTMLElement, target: EventTarget | null): boolean => {
|
|
||||||
const nested = nestedScrollableTarget(root, target);
|
|
||||||
if (!nested) return false;
|
|
||||||
return nested.scrollTop > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useChatAutoFollow = ({
|
|
||||||
currentSessionId,
|
|
||||||
currentSessionKey,
|
|
||||||
sessionMessageCount,
|
|
||||||
sessionIsWorking,
|
|
||||||
isMobile,
|
|
||||||
onActiveTurnChange,
|
|
||||||
}: UseChatAutoFollowOptions): UseChatAutoFollowResult => {
|
|
||||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
|
||||||
const [containerEl, setContainerEl] = React.useState<HTMLDivElement | null>(null);
|
|
||||||
const lastSeenContainerRef = React.useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const [state, setState] = React.useState<AutoFollowState>('following');
|
|
||||||
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
|
||||||
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
|
||||||
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
|
|
||||||
|
|
||||||
// `stateRef` is the single source of truth for follow vs released; the React
|
|
||||||
// state above is a mirror for rendering. `released` means the user scrolled
|
|
||||||
// up and away from the bottom.
|
|
||||||
const stateRef = React.useRef<AutoFollowState>('following');
|
|
||||||
const isMobileRef = React.useRef(isMobile);
|
|
||||||
isMobileRef.current = isMobile;
|
|
||||||
const sessionIsWorkingRef = React.useRef(sessionIsWorking);
|
|
||||||
sessionIsWorkingRef.current = sessionIsWorking;
|
|
||||||
// `settling` keeps passive follow alive for a short window after work stops
|
|
||||||
// so the final content can land at the bottom.
|
|
||||||
const settlingRef = React.useRef(false);
|
|
||||||
const settleTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const sessionMessageCountRef = React.useRef(sessionMessageCount);
|
|
||||||
sessionMessageCountRef.current = sessionMessageCount;
|
|
||||||
const currentSessionIdRef = React.useRef(currentSessionId);
|
|
||||||
currentSessionIdRef.current = currentSessionId;
|
|
||||||
const currentSessionKeyRef = React.useRef(currentSessionKey);
|
|
||||||
currentSessionKeyRef.current = currentSessionKey;
|
|
||||||
|
|
||||||
const lastSessionKeyRef = React.useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Programmatic-scroll marker: the bottom position we last
|
|
||||||
// wrote and when. A scroll event whose scrollTop matches `top` within a few
|
|
||||||
// px while still inside the TTL is OUR write, not the user's.
|
|
||||||
const autoRef = React.useRef<{ top: number; time: number } | null>(null);
|
|
||||||
const autoTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
// Timestamp until which a tracked height animation is in flight (see
|
|
||||||
// ANIMATION_GUARD_MS). 0 = no animation guard active.
|
|
||||||
const animationGuardUntilRef = React.useRef(0);
|
|
||||||
|
|
||||||
// True while the native (Capacitor iOS) keyboard slide choreography is in
|
|
||||||
// flight (between 'oc:keyboard-anim' and 'oc:keyboard-settled' from
|
|
||||||
// useNativeMobileChrome). During that window the pinned content is moved by a
|
|
||||||
// transform on the inner wrapper, so the ResizeObserver chase must stand down.
|
|
||||||
const keyboardAnimRef = React.useRef(false);
|
|
||||||
|
|
||||||
// Last observed scrollTop, used to derive scroll DIRECTION in the scroll
|
|
||||||
// handler so the bottom-zone re-engage only fires when arriving at the bottom
|
|
||||||
// by scrolling down — never when a user scrolling UP merely lands in the zone.
|
|
||||||
const lastScrollTopRef = React.useRef(0);
|
|
||||||
|
|
||||||
// Entry-stick window state (see ENTRY_STICK_* above).
|
|
||||||
const entryStickRef = React.useRef(false);
|
|
||||||
const entryStickQuietTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const entryStickCapTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const entryStickLastHeightRef = React.useRef(0);
|
|
||||||
|
|
||||||
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
|
||||||
// When restoreSnapshot is invoked while ChatViewport is still hydrating
|
|
||||||
// (skeleton rendered, no scroll container yet), we record the session here
|
|
||||||
// so a follow-up effect can replay the restore once the container mounts.
|
|
||||||
const pendingInitialRestoreRef = React.useRef<string | null>(null);
|
|
||||||
|
|
||||||
const updateViewportAnchor = useViewportStore((s) => s.updateViewportAnchor);
|
|
||||||
|
|
||||||
// Detect when the scroll container DOM element changes (mount, unmount, remount).
|
|
||||||
// Without this, listener-attach effects would only ever bind to the element that
|
|
||||||
// existed at the hook's first render, missing later mounts (e.g. after first send
|
|
||||||
// promotes a draft session to a real chat with messages).
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
if (scrollRef.current !== lastSeenContainerRef.current) {
|
|
||||||
lastSeenContainerRef.current = scrollRef.current;
|
|
||||||
setContainerEl(scrollRef.current);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// `active` is `working || settling`. Passive auto-follow
|
|
||||||
// (the ResizeObserver re-pin and any non-forced scrollToBottom) only runs
|
|
||||||
// while active. When the session is idle, content-size changes are layout
|
|
||||||
// churn — virtualizer re-measurement, async tool/code rendering — NOT live
|
|
||||||
// growth, so we must NOT yank the user to the bottom. Forcing this gate is
|
|
||||||
// what stops the twitch when tall items (expanded tools) re-measure as the
|
|
||||||
// user scrolls.
|
|
||||||
const isActive = React.useCallback((): boolean => {
|
|
||||||
return sessionIsWorkingRef.current || settlingRef.current;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setStateValue = React.useCallback((next: AutoFollowState) => {
|
|
||||||
if (stateRef.current === next) return;
|
|
||||||
stateRef.current = next;
|
|
||||||
setState(next);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── auto marker ────────────────────────────────────────────────────────
|
|
||||||
const markAuto = React.useCallback((el: HTMLElement) => {
|
|
||||||
autoRef.current = {
|
|
||||||
top: Math.max(0, el.scrollHeight - el.clientHeight),
|
|
||||||
time: now(),
|
|
||||||
};
|
|
||||||
if (autoTimerRef.current) clearTimeout(autoTimerRef.current);
|
|
||||||
autoTimerRef.current = setTimeout(() => {
|
|
||||||
autoRef.current = null;
|
|
||||||
autoTimerRef.current = null;
|
|
||||||
}, AUTO_MARK_TTL_MS);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isAuto = React.useCallback((el: HTMLElement): boolean => {
|
|
||||||
const a = autoRef.current;
|
|
||||||
if (!a) return false;
|
|
||||||
if (now() - a.time > AUTO_MARK_TTL_MS) {
|
|
||||||
autoRef.current = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return Math.abs(el.scrollTop - a.top) < AUTO_MATCH_TOLERANCE_PX;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isAnimationGuardActive = React.useCallback((): boolean => {
|
|
||||||
return now() < animationGuardUntilRef.current;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── entry-stick window ───────────────────────────────────────────────────
|
|
||||||
const endEntryStick = React.useCallback(() => {
|
|
||||||
entryStickRef.current = false;
|
|
||||||
if (entryStickQuietTimerRef.current) {
|
|
||||||
clearTimeout(entryStickQuietTimerRef.current);
|
|
||||||
entryStickQuietTimerRef.current = null;
|
|
||||||
}
|
|
||||||
if (entryStickCapTimerRef.current) {
|
|
||||||
clearTimeout(entryStickCapTimerRef.current);
|
|
||||||
entryStickCapTimerRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// (Re)arm the quiescence timer: the window closes this long after the last
|
|
||||||
// growth. Called once on begin and again on every growth-driven re-pin.
|
|
||||||
const armEntryStickQuiet = React.useCallback(() => {
|
|
||||||
if (entryStickQuietTimerRef.current) {
|
|
||||||
clearTimeout(entryStickQuietTimerRef.current);
|
|
||||||
}
|
|
||||||
entryStickQuietTimerRef.current = setTimeout(() => {
|
|
||||||
entryStickQuietTimerRef.current = null;
|
|
||||||
endEntryStick();
|
|
||||||
}, ENTRY_STICK_QUIESCENCE_MS);
|
|
||||||
}, [endEntryStick]);
|
|
||||||
|
|
||||||
const beginEntryStick = React.useCallback(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
entryStickRef.current = true;
|
|
||||||
entryStickLastHeightRef.current = el.scrollHeight;
|
|
||||||
armEntryStickQuiet();
|
|
||||||
// Reset the absolute cap fresh on every entry (e.g. session switch) so a
|
|
||||||
// stale cap from a previous open can't cut this window short.
|
|
||||||
if (entryStickCapTimerRef.current) {
|
|
||||||
clearTimeout(entryStickCapTimerRef.current);
|
|
||||||
}
|
|
||||||
entryStickCapTimerRef.current = setTimeout(() => {
|
|
||||||
entryStickCapTimerRef.current = null;
|
|
||||||
endEntryStick();
|
|
||||||
}, ENTRY_STICK_MAX_MS);
|
|
||||||
}, [armEntryStickQuiet, endEntryStick]);
|
|
||||||
|
|
||||||
// ── overflow / scroll-to-bottom button ──────────────────────────────────
|
|
||||||
const updateOverflowAndButton = React.useCallback(() => {
|
|
||||||
const container = scrollRef.current;
|
|
||||||
if (!container) {
|
|
||||||
setIsOverflowing(false);
|
|
||||||
setShowScrollButton(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const overflowing = canScroll(container);
|
|
||||||
setIsOverflowing(overflowing);
|
|
||||||
if (!overflowing) {
|
|
||||||
setShowScrollButton(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const showButton = stateRef.current === 'released' && !isNearBottom(container, isMobileRef.current);
|
|
||||||
setShowScrollButton(showButton);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── core scroll primitives ───────────────────────────────────────────────
|
|
||||||
const scrollToBottomNow = React.useCallback((behavior: ScrollBehavior) => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
markAuto(el);
|
|
||||||
// `scrollHeight` is rounded to an integer while the real content height
|
|
||||||
// is fractional (prose line-heights), so `scrollTop = scrollHeight`
|
|
||||||
// leaves a 0–1px remainder that oscillates per streamed token and makes
|
|
||||||
// bottom-anchored rows jitter vertically. An over-large target clamps to
|
|
||||||
// the exact fractional maximum instead, pinning content to the bottom.
|
|
||||||
const overshootTarget = el.scrollHeight + 4096;
|
|
||||||
if (behavior === 'smooth') {
|
|
||||||
el.scrollTo({ top: overshootTarget, behavior });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Direct `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`
|
|
||||||
// and lands in the same frame — no visible catch-up animation.
|
|
||||||
el.scrollTop = overshootTarget;
|
|
||||||
}, [markAuto]);
|
|
||||||
|
|
||||||
// `force` true = user-intent jump (clears released and always scrolls).
|
|
||||||
// `force` false = passive follow (only while still following).
|
|
||||||
const scrollToBottom = React.useCallback((force: boolean, behavior: ScrollBehavior = 'auto') => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
|
|
||||||
// Passive follow only while active (working/settling). Forced jumps
|
|
||||||
// (send, go-to-bottom, session restore) always proceed.
|
|
||||||
if (!force && !isActive()) return;
|
|
||||||
|
|
||||||
if (force && stateRef.current !== 'following') {
|
|
||||||
setStateValue('following');
|
|
||||||
}
|
|
||||||
if (!el) return;
|
|
||||||
if (!force && stateRef.current !== 'following') return;
|
|
||||||
|
|
||||||
// Always re-pin, even when already within tolerance of the bottom.
|
|
||||||
// Sub-tolerance growth (fractional line-height remainders) would
|
|
||||||
// otherwise leave the bottom drifting by up to ±AUTO_MATCH_TOLERANCE_PX
|
|
||||||
// between full re-pins, which reads as 1px vertical jitter on
|
|
||||||
// bottom-anchored rows during streaming. The write happens pre-paint
|
|
||||||
// (ResizeObserver) and is a no-op when the position is unchanged.
|
|
||||||
scrollToBottomNow(force ? behavior : 'auto');
|
|
||||||
}, [isActive, scrollToBottomNow, setStateValue]);
|
|
||||||
|
|
||||||
// User left the bottom — release auto-follow.
|
|
||||||
const stop = React.useCallback(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
if (!canScroll(el)) {
|
|
||||||
setStateValue('following');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (stateRef.current === 'released') return;
|
|
||||||
setStateValue('released');
|
|
||||||
updateOverflowAndButton();
|
|
||||||
}, [setStateValue, updateOverflowAndButton]);
|
|
||||||
|
|
||||||
// ── public scroll API (mapped onto the primitives) ───────────────────────
|
|
||||||
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
|
|
||||||
scrollToBottom(true, mode === 'smooth' ? 'smooth' : 'auto');
|
|
||||||
}, [scrollToBottom]);
|
|
||||||
|
|
||||||
const scrollToBottomOnSend = React.useCallback(() => {
|
|
||||||
// Single movement to the just-sent message. Force re-pins to the bottom
|
|
||||||
// whether we were following or scrolled up; the content ResizeObserver
|
|
||||||
// keeps us pinned as the optimistic message and its reply stream in.
|
|
||||||
scrollToBottom(true);
|
|
||||||
}, [scrollToBottom]);
|
|
||||||
|
|
||||||
const releaseAutoFollow = React.useCallback(() => {
|
|
||||||
setStateValue('released');
|
|
||||||
updateOverflowAndButton();
|
|
||||||
}, [setStateValue, updateOverflowAndButton]);
|
|
||||||
|
|
||||||
const releaseFromUserIntent = React.useCallback(() => {
|
|
||||||
// A genuine user gesture (wheel/touch/key/scrollbar) cancels the entry
|
|
||||||
// window immediately so we never fight the user's read position.
|
|
||||||
endEntryStick();
|
|
||||||
stop();
|
|
||||||
}, [endEntryStick, stop]);
|
|
||||||
|
|
||||||
// ── per-session snapshot persistence (kept; restore still goes to bottom) ─
|
|
||||||
const flushSave = React.useCallback(() => {
|
|
||||||
if (saveTimerRef.current !== null) {
|
|
||||||
clearTimeout(saveTimerRef.current);
|
|
||||||
saveTimerRef.current = null;
|
|
||||||
}
|
|
||||||
const pending = pendingSaveRef.current;
|
|
||||||
if (!pending) return;
|
|
||||||
const container = scrollRef.current;
|
|
||||||
if (!container) {
|
|
||||||
pendingSaveRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateViewportAnchor(pending.sessionId, pending.anchor, {
|
|
||||||
scrollTop: container.scrollTop,
|
|
||||||
scrollHeight: container.scrollHeight,
|
|
||||||
clientHeight: container.clientHeight,
|
|
||||||
});
|
|
||||||
pendingSaveRef.current = null;
|
|
||||||
}, [updateViewportAnchor]);
|
|
||||||
|
|
||||||
const queueSave = React.useCallback(() => {
|
|
||||||
const sessionId = currentSessionIdRef.current;
|
|
||||||
if (!sessionId) return;
|
|
||||||
const container = scrollRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
||||||
const anchorRatio = scrollHeight > 0
|
|
||||||
? (scrollTop + clientHeight / 2) / scrollHeight
|
|
||||||
: 0;
|
|
||||||
const anchor = Math.floor(anchorRatio * sessionMessageCountRef.current);
|
|
||||||
|
|
||||||
pendingSaveRef.current = { sessionId, anchor };
|
|
||||||
if (saveTimerRef.current !== null) return;
|
|
||||||
saveTimerRef.current = setTimeout(() => {
|
|
||||||
saveTimerRef.current = null;
|
|
||||||
flushSave();
|
|
||||||
}, SAVE_DEBOUNCE_MS);
|
|
||||||
}, [flushSave]);
|
|
||||||
|
|
||||||
const saveSnapshotNow = React.useCallback(() => {
|
|
||||||
flushSave();
|
|
||||||
}, [flushSave]);
|
|
||||||
|
|
||||||
const restoreSnapshot = React.useCallback(async (): Promise<boolean> => {
|
|
||||||
const sessionKey = currentSessionKeyRef.current;
|
|
||||||
if (!sessionKey) return false;
|
|
||||||
|
|
||||||
const container = scrollRef.current;
|
|
||||||
if (!container) {
|
|
||||||
// ChatViewport not mounted yet (e.g., session still hydrating).
|
|
||||||
// Record the request so the container-attach effect can replay it.
|
|
||||||
pendingInitialRestoreRef.current = sessionKey;
|
|
||||||
setStateValue('following');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
pendingInitialRestoreRef.current = null;
|
|
||||||
|
|
||||||
// Always return to the bottom on session switch. The content
|
|
||||||
// ResizeObserver re-pins instantly as late
|
|
||||||
// history measures in, so there is no smooth scroll-from-mid artifact.
|
|
||||||
setStateValue('following');
|
|
||||||
scrollToBottom(true);
|
|
||||||
// Hold the bottom across late async growth (e.g. task/subagent child
|
|
||||||
// session data landing a beat after entry) until content quiesces or the
|
|
||||||
// user scrolls.
|
|
||||||
beginEntryStick();
|
|
||||||
updateOverflowAndButton();
|
|
||||||
return false;
|
|
||||||
}, [beginEntryStick, scrollToBottom, setStateValue, updateOverflowAndButton]);
|
|
||||||
|
|
||||||
// ── session change ───────────────────────────────────────────────────────
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!currentSessionId || !currentSessionKey || currentSessionKey === lastSessionKeyRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastSessionKeyRef.current = currentSessionKey;
|
|
||||||
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
|
||||||
flushSave();
|
|
||||||
autoRef.current = null;
|
|
||||||
// Drop any pending restore request inherited from a different session.
|
|
||||||
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current !== currentSessionKey) {
|
|
||||||
pendingInitialRestoreRef.current = null;
|
|
||||||
}
|
|
||||||
}, [currentSessionId, currentSessionKey, flushSave]);
|
|
||||||
|
|
||||||
// When work begins and we are still
|
|
||||||
// following, pin to the bottom. When work stops, keep following alive for a
|
|
||||||
// short settle window so the final content lands at the bottom, then go
|
|
||||||
// idle (after which passive follow is disabled — see `isActive`).
|
|
||||||
React.useEffect(() => {
|
|
||||||
settlingRef.current = false;
|
|
||||||
if (settleTimerRef.current) {
|
|
||||||
clearTimeout(settleTimerRef.current);
|
|
||||||
settleTimerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionIsWorking) {
|
|
||||||
if (stateRef.current === 'following') {
|
|
||||||
scrollToBottom(true);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
settlingRef.current = true;
|
|
||||||
settleTimerRef.current = setTimeout(() => {
|
|
||||||
settlingRef.current = false;
|
|
||||||
settleTimerRef.current = null;
|
|
||||||
}, SETTLE_MS);
|
|
||||||
}, [sessionIsWorking, scrollToBottom]);
|
|
||||||
|
|
||||||
// Suppress the overlay scrollbar thumb only while we are actively following a
|
|
||||||
// live stream (the thumb would otherwise jump on every instant re-pin). When
|
|
||||||
// idle or released the scrollbar behaves normally. Stable: changes only when
|
|
||||||
// follow-state or working-state flips, not on every frame.
|
|
||||||
React.useEffect(() => {
|
|
||||||
setIsFollowingProgrammatically(state === 'following' && sessionIsWorking);
|
|
||||||
}, [state, sessionIsWorking]);
|
|
||||||
|
|
||||||
// Replay a deferred restoreSnapshot once ChatViewport mounts.
|
|
||||||
// useLayoutEffect ensures scroll position is set before the browser paints,
|
|
||||||
// preventing a visible flash of content at the wrong scroll position.
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
if (!containerEl) return;
|
|
||||||
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current === currentSessionKey) {
|
|
||||||
void restoreSnapshot();
|
|
||||||
}
|
|
||||||
}, [containerEl, currentSessionKey, restoreSnapshot]);
|
|
||||||
|
|
||||||
// ── scroll event handling ────────────────────────────────────────────────
|
|
||||||
const handleScrollEvent = React.useCallback(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
const previousTop = lastScrollTopRef.current;
|
|
||||||
lastScrollTopRef.current = el.scrollTop;
|
|
||||||
const scrollingDown = el.scrollTop > previousTop + 0.5;
|
|
||||||
|
|
||||||
updateOverflowAndButton();
|
|
||||||
|
|
||||||
if (!canScroll(el)) {
|
|
||||||
setStateValue('following');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Within the bottom zone → (re-)pin to following. This is how scrolling
|
|
||||||
// back DOWN to the bottom resumes auto-follow. Crucially, re-engage only
|
|
||||||
// when the user arrives by scrolling down (or is already following, or is
|
|
||||||
// essentially at the true bottom). A user scrolling UP that merely lands
|
|
||||||
// in the bottom spacer zone must NOT be yanked back into follow — that is
|
|
||||||
// the dead-zone fight that made small upward scrolls impossible while
|
|
||||||
// content streams.
|
|
||||||
if (isNearBottom(el, isMobileRef.current)) {
|
|
||||||
const atTrueBottom = distanceFromBottom(el) <= AUTO_MATCH_TOLERANCE_PX;
|
|
||||||
if (scrollingDown || stateRef.current === 'following' || atTrueBottom) {
|
|
||||||
setStateValue('following');
|
|
||||||
}
|
|
||||||
queueSave();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Our own geometry change (a programmatic write that landed at the bottom
|
|
||||||
// but where content grew between the write and this event, OR a tracked
|
|
||||||
// height animation in flight) — keep following, don't release.
|
|
||||||
if (stateRef.current === 'following' && (isAuto(el) || isAnimationGuardActive())) {
|
|
||||||
scrollToBottom(false);
|
|
||||||
queueSave();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Genuine user scroll away from the bottom.
|
|
||||||
stop();
|
|
||||||
queueSave();
|
|
||||||
}, [isAnimationGuardActive, isAuto, queueSave, scrollToBottom, setStateValue, stop, updateOverflowAndButton]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const container = containerEl;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
lastScrollTopRef.current = container.scrollTop;
|
|
||||||
|
|
||||||
const handleWheel = (event: WheelEvent) => {
|
|
||||||
if (event.deltaY >= 0) return;
|
|
||||||
if (nestedScrollableCanConsumeUp(container, event.target)) return;
|
|
||||||
releaseFromUserIntent();
|
|
||||||
};
|
|
||||||
|
|
||||||
let touchLastY: number | null = null;
|
|
||||||
const handleTouchStart = (event: TouchEvent) => {
|
|
||||||
const touch = event.touches.item(0);
|
|
||||||
touchLastY = touch ? touch.clientY : null;
|
|
||||||
};
|
|
||||||
const handleTouchMove = (event: TouchEvent) => {
|
|
||||||
const touch = event.touches.item(0);
|
|
||||||
if (!touch) {
|
|
||||||
touchLastY = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const previousY = touchLastY;
|
|
||||||
touchLastY = touch.clientY;
|
|
||||||
if (previousY === null) return;
|
|
||||||
const fingerDelta = touch.clientY - previousY;
|
|
||||||
if (fingerDelta <= TOUCH_FINGER_DOWN_THRESHOLD) return;
|
|
||||||
if (nestedScrollableCanConsumeUp(container, event.target)) return;
|
|
||||||
releaseFromUserIntent();
|
|
||||||
};
|
|
||||||
const handleTouchEnd = () => {
|
|
||||||
touchLastY = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (!isReleaseKey(event)) return;
|
|
||||||
releaseFromUserIntent();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerDownIntent = (event: PointerEvent) => {
|
|
||||||
const target = event.target;
|
|
||||||
if (!(target instanceof Element)) return;
|
|
||||||
if (!target.closest('[data-overlay-scrollbar-thumb]')) return;
|
|
||||||
releaseFromUserIntent();
|
|
||||||
};
|
|
||||||
|
|
||||||
container.addEventListener('scroll', handleScrollEvent, { passive: true });
|
|
||||||
container.addEventListener('wheel', handleWheel, { passive: true });
|
|
||||||
container.addEventListener('touchstart', handleTouchStart, { passive: true });
|
|
||||||
container.addEventListener('touchmove', handleTouchMove, { passive: true });
|
|
||||||
container.addEventListener('touchend', handleTouchEnd, { passive: true });
|
|
||||||
container.addEventListener('touchcancel', handleTouchEnd, { passive: true });
|
|
||||||
container.addEventListener('keydown', handleKeyDown);
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.addEventListener('pointerdown', handlePointerDownIntent, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
container.removeEventListener('scroll', handleScrollEvent);
|
|
||||||
container.removeEventListener('wheel', handleWheel);
|
|
||||||
container.removeEventListener('touchstart', handleTouchStart);
|
|
||||||
container.removeEventListener('touchmove', handleTouchMove);
|
|
||||||
container.removeEventListener('touchend', handleTouchEnd);
|
|
||||||
container.removeEventListener('touchcancel', handleTouchEnd);
|
|
||||||
container.removeEventListener('keydown', handleKeyDown);
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.removeEventListener('pointerdown', handlePointerDownIntent, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [containerEl, handleScrollEvent, releaseFromUserIntent]);
|
|
||||||
|
|
||||||
// The heart of the follow behaviour: the content ResizeObserver fires after
|
|
||||||
// layout and before paint, so re-pinning to the bottom here is invisible —
|
|
||||||
// there is no "jump up then catch up". Observe both the container (composer
|
|
||||||
// growth shrinks the viewport) and the inner content (streaming growth).
|
|
||||||
React.useEffect(() => {
|
|
||||||
const container = containerEl;
|
|
||||||
if (!container || typeof ResizeObserver === 'undefined') return;
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
|
||||||
// Keyboard slide in flight: the container/composer resizes it reports
|
|
||||||
// are part of the transform choreography — the settle handler does the
|
|
||||||
// single deterministic re-pin, so chasing here would just fight it.
|
|
||||||
if (keyboardAnimRef.current) {
|
|
||||||
updateOverflowAndButton();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (el && !canScroll(el)) {
|
|
||||||
setStateValue('following');
|
|
||||||
updateOverflowAndButton();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateOverflowAndButton();
|
|
||||||
// Entry-stick window: on first session open, FORCE the bottom on
|
|
||||||
// every growth so late async data (task/subagent child rows, code
|
|
||||||
// highlight, mermaid) can't strand the viewport mid-history. Force
|
|
||||||
// overrides any false `released` from the growth itself; only a real
|
|
||||||
// user gesture clears the window (releaseFromUserIntent).
|
|
||||||
if (entryStickRef.current && el) {
|
|
||||||
const grew = el.scrollHeight > entryStickLastHeightRef.current + 1;
|
|
||||||
entryStickLastHeightRef.current = el.scrollHeight;
|
|
||||||
scrollToBottom(true);
|
|
||||||
if (grew) armEntryStickQuiet();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Idle resize = layout churn (virtualizer re-measurement, async
|
|
||||||
// tool/code rendering), NOT live growth. Never re-pin when idle, or
|
|
||||||
// tall items re-measuring as the user scrolls cause an endless
|
|
||||||
// scroll-to-bottom/re-measure twitch.
|
|
||||||
if (!isActive()) return;
|
|
||||||
if (stateRef.current !== 'following') return;
|
|
||||||
scrollToBottom(false);
|
|
||||||
});
|
|
||||||
observer.observe(container);
|
|
||||||
const inner = container.firstElementChild;
|
|
||||||
if (inner instanceof Element) {
|
|
||||||
observer.observe(inner);
|
|
||||||
}
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [armEntryStickQuiet, containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]);
|
|
||||||
|
|
||||||
// ── native keyboard transitions (Capacitor choreography) ────────────────
|
|
||||||
// The chat scroller gets NO transforms during the keyboard transition:
|
|
||||||
// transforming the scroll container (or its content) forces WebKit to
|
|
||||||
// rebuild the composited scrolling layers, which stalls for seconds on
|
|
||||||
// long chats. Instead the chat repositions with instant snaps that hide
|
|
||||||
// behind the keyboard itself:
|
|
||||||
// show: content stays put while the keyboard/composer slide over it; the
|
|
||||||
// settled event (shell layout snap) does ONE instant re-pin.
|
|
||||||
// hide: the shell layout is restored up-front — the scrollTop clamp
|
|
||||||
// happens while the keyboard still covers that region — and the
|
|
||||||
// settled event re-pins once at the end.
|
|
||||||
// During the window we only guard the scroll heuristics and the observer
|
|
||||||
// chase. These events never fire outside the Capacitor app.
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
const handleKeyboardAnim = (event: Event) => {
|
|
||||||
const detail = (event as CustomEvent<{ phase: 'show' | 'hide'; slide: number; durationMs: number; easing: string }>).detail;
|
|
||||||
if (!detail) return;
|
|
||||||
keyboardAnimRef.current = true;
|
|
||||||
// The clamp/resize during the choreography can dispatch scroll events
|
|
||||||
// that land away from the auto marker — never read those as a user
|
|
||||||
// scroll-away.
|
|
||||||
animationGuardUntilRef.current = now() + detail.durationMs + ANIMATION_GUARD_MS;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyboardSettled = () => {
|
|
||||||
keyboardAnimRef.current = false;
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) {
|
|
||||||
updateOverflowAndButton();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Single deterministic re-pin, same task as the layout swap → lands
|
|
||||||
// before paint. (scrollToBottomNow, not scrollToBottom: this must not
|
|
||||||
// be gated on working/settling — the keyboard resize is a viewport
|
|
||||||
// change, not content growth.)
|
|
||||||
if (stateRef.current === 'following' && canScroll(el)) {
|
|
||||||
scrollToBottomNow('auto');
|
|
||||||
}
|
|
||||||
updateOverflowAndButton();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('oc:keyboard-anim', handleKeyboardAnim);
|
|
||||||
window.addEventListener('oc:keyboard-settled', handleKeyboardSettled);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('oc:keyboard-anim', handleKeyboardAnim);
|
|
||||||
window.removeEventListener('oc:keyboard-settled', handleKeyboardSettled);
|
|
||||||
keyboardAnimRef.current = false;
|
|
||||||
};
|
|
||||||
}, [scrollToBottomNow, updateOverflowAndButton]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
updateOverflowAndButton();
|
|
||||||
}, [sessionMessageCount, updateOverflowAndButton]);
|
|
||||||
|
|
||||||
const notifyContentChange = React.useCallback((reason?: ContentChangeReason) => {
|
|
||||||
// A tracked height animation (e.g. Thinking auto-collapse) opens a guard
|
|
||||||
// window so its transient geometry / async scroll events are not misread
|
|
||||||
// as a user scroll-away. Real gestures still release through
|
|
||||||
// releaseFromUserIntent, so the user can always scroll up freely.
|
|
||||||
if (reason === 'animation') {
|
|
||||||
animationGuardUntilRef.current = now() + ANIMATION_GUARD_MS;
|
|
||||||
}
|
|
||||||
updateOverflowAndButton();
|
|
||||||
// Entry-stick window: late structural growth (notably the task/subagent
|
|
||||||
// summary landing from the child session — ToolPart emits 'structural'
|
|
||||||
// here) must keep us pinned and refresh the quiescence timer, even though
|
|
||||||
// the session is idle.
|
|
||||||
if (entryStickRef.current) {
|
|
||||||
scrollToBottom(true);
|
|
||||||
armEntryStickQuiet();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (stateRef.current === 'following') {
|
|
||||||
scrollToBottom(false);
|
|
||||||
}
|
|
||||||
}, [armEntryStickQuiet, scrollToBottom, updateOverflowAndButton]);
|
|
||||||
|
|
||||||
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
|
|
||||||
|
|
||||||
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
|
|
||||||
const cached = animationHandlersRef.current.get(messageId);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const kick = () => {
|
|
||||||
if (stateRef.current === 'following') {
|
|
||||||
scrollToBottom(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlers: AnimationHandlers = {
|
|
||||||
onChunk: kick,
|
|
||||||
onComplete: () => {
|
|
||||||
updateOverflowAndButton();
|
|
||||||
},
|
|
||||||
onStreamingCandidate: () => {},
|
|
||||||
onAnimationStart: () => {},
|
|
||||||
onAnimatedHeightChange: kick,
|
|
||||||
onReservationCancelled: () => {},
|
|
||||||
onReasoningBlock: () => {},
|
|
||||||
};
|
|
||||||
animationHandlersRef.current.set(messageId, handlers);
|
|
||||||
return handlers;
|
|
||||||
}, [scrollToBottom, updateOverflowAndButton]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (autoTimerRef.current) {
|
|
||||||
clearTimeout(autoTimerRef.current);
|
|
||||||
autoTimerRef.current = null;
|
|
||||||
}
|
|
||||||
if (settleTimerRef.current) {
|
|
||||||
clearTimeout(settleTimerRef.current);
|
|
||||||
settleTimerRef.current = null;
|
|
||||||
}
|
|
||||||
endEntryStick();
|
|
||||||
flushSave();
|
|
||||||
if (saveTimerRef.current !== null) {
|
|
||||||
clearTimeout(saveTimerRef.current);
|
|
||||||
saveTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [endEntryStick, flushSave]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!onActiveTurnChange) return;
|
|
||||||
const container = containerEl;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
let lastActiveTurnId: string | null = null;
|
|
||||||
const spy = createScrollSpy({
|
|
||||||
onActive: (turnId) => {
|
|
||||||
if (turnId === lastActiveTurnId) return;
|
|
||||||
lastActiveTurnId = turnId;
|
|
||||||
onActiveTurnChange(turnId);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
spy.setContainer(container);
|
|
||||||
|
|
||||||
const elementByTurnId = new Map<string, HTMLElement>();
|
|
||||||
const registerTurnNode = (node: HTMLElement) => {
|
|
||||||
const turnId = node.dataset.turnId;
|
|
||||||
if (!turnId) return false;
|
|
||||||
elementByTurnId.set(turnId, node);
|
|
||||||
spy.register(node, turnId);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
const unregisterTurnNode = (node: HTMLElement) => {
|
|
||||||
const turnId = node.dataset.turnId;
|
|
||||||
if (!turnId) return false;
|
|
||||||
if (elementByTurnId.get(turnId) !== node) return false;
|
|
||||||
elementByTurnId.delete(turnId);
|
|
||||||
spy.unregister(turnId);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
const collectTurnNodes = (node: Node): HTMLElement[] => {
|
|
||||||
if (!(node instanceof HTMLElement)) return [];
|
|
||||||
const collected: HTMLElement[] = [];
|
|
||||||
if (node.matches('[data-turn-id]')) collected.push(node);
|
|
||||||
node.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((el) => collected.push(el));
|
|
||||||
return collected;
|
|
||||||
};
|
|
||||||
|
|
||||||
container.querySelectorAll<HTMLElement>('[data-turn-id]').forEach(registerTurnNode);
|
|
||||||
spy.markDirty();
|
|
||||||
|
|
||||||
const mutationObserver = new MutationObserver((records) => {
|
|
||||||
let changed = false;
|
|
||||||
records.forEach((record) => {
|
|
||||||
record.removedNodes.forEach((node) => {
|
|
||||||
collectTurnNodes(node).forEach((turnNode) => {
|
|
||||||
if (unregisterTurnNode(turnNode)) changed = true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
record.addedNodes.forEach((node) => {
|
|
||||||
collectTurnNodes(node).forEach((turnNode) => {
|
|
||||||
if (registerTurnNode(turnNode)) changed = true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (changed) spy.markDirty();
|
|
||||||
});
|
|
||||||
mutationObserver.observe(container, { subtree: true, childList: true });
|
|
||||||
|
|
||||||
const onScroll = () => spy.onScroll();
|
|
||||||
container.addEventListener('scroll', onScroll, { passive: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
container.removeEventListener('scroll', onScroll);
|
|
||||||
mutationObserver.disconnect();
|
|
||||||
spy.destroy();
|
|
||||||
};
|
|
||||||
}, [containerEl, onActiveTurnChange]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
scrollRef,
|
|
||||||
state,
|
|
||||||
isPinned: state === 'following',
|
|
||||||
isOverflowing,
|
|
||||||
isFollowingProgrammatically,
|
|
||||||
showScrollButton,
|
|
||||||
notifyContentChange,
|
|
||||||
getAnimationHandlers,
|
|
||||||
goToBottom,
|
|
||||||
scrollToBottomOnSend,
|
|
||||||
releaseAutoFollow,
|
|
||||||
saveSnapshotNow,
|
|
||||||
restoreSnapshot,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
||||||
|
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
|
||||||
|
import { useViewportStore } from '@/sync/viewport-store';
|
||||||
|
import {
|
||||||
|
CHAT_LIST_ANCHOR_OFFSET,
|
||||||
|
getAnchoredTurnMetrics,
|
||||||
|
type TimelineListMeasurementState,
|
||||||
|
type TimelineScrollMode,
|
||||||
|
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Chat timeline scroll ownership.
|
||||||
|
//
|
||||||
|
// The virtualized list owns the scroll position; this hook only decides which
|
||||||
|
// of three mutually exclusive modes is active and, when a mode calls for it,
|
||||||
|
// issues ONE deterministic scroll command:
|
||||||
|
//
|
||||||
|
// • `following-end` — pinned to the live edge. The list keeps us there
|
||||||
|
// through `maintainScrollAtEnd`; we only re-assert after a data change.
|
||||||
|
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
|
||||||
|
// of the viewport and the reply streams into the reserved end space below
|
||||||
|
// it. The viewport does NOT move while the turn still fits; once the turn
|
||||||
|
// outgrows the usable viewport we scroll by the exact delta needed to keep
|
||||||
|
// its end visible.
|
||||||
|
// • `free-scrolling` — the user took over. Nothing moves until they opt
|
||||||
|
// back in by returning to the end.
|
||||||
|
//
|
||||||
|
// Opting out of automatic movement is driven by REAL gestures (wheel /
|
||||||
|
// touchmove / pointerdown), not by inferring intent from scroll positions. Each
|
||||||
|
// gesture bumps a generation counter; any in-flight automatic movement compares
|
||||||
|
// its captured generation against the current one and aborts if they differ.
|
||||||
|
// That comparison replaces the timer windows the previous implementation needed
|
||||||
|
// to tell its own writes apart from the user's, which is why there are no
|
||||||
|
// guard/settle/entry-stick timers here.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Kept for source compatibility with message parts that report content growth.
|
||||||
|
// Growth no longer drives scrolling — the list handles it — so these are inert,
|
||||||
|
// but the prop threads through many part components and removing the contract
|
||||||
|
// is a separate change.
|
||||||
|
export type ContentChangeReason = 'text' | 'structural' | 'permission' | 'animation';
|
||||||
|
|
||||||
|
export interface AnimationHandlers {
|
||||||
|
onChunk: () => void;
|
||||||
|
onComplete: () => void;
|
||||||
|
onStreamingCandidate?: () => void;
|
||||||
|
onAnimationStart?: () => void;
|
||||||
|
onReservationCancelled?: () => void;
|
||||||
|
onReasoningBlock?: () => void;
|
||||||
|
onAnimatedHeightChange?: (height: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The subset of the list ref this hook drives. Declared structurally so the
|
||||||
|
// hook stays testable without a renderer and does not hard-depend on the list
|
||||||
|
// implementation.
|
||||||
|
export interface TimelineListHandle {
|
||||||
|
getState: () => TimelineListMeasurementState & { readonly scroll: number };
|
||||||
|
getScrollableNode: () => HTMLElement | null;
|
||||||
|
scrollToEnd: (options?: { animated?: boolean }) => unknown;
|
||||||
|
scrollToOffset: (params: { offset: number; animated?: boolean }) => unknown;
|
||||||
|
scrollToIndex: (params: {
|
||||||
|
index: number;
|
||||||
|
animated?: boolean;
|
||||||
|
viewPosition?: number;
|
||||||
|
viewOffset?: number;
|
||||||
|
}) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseChatTimelineScrollOptions {
|
||||||
|
currentSessionId: string | null;
|
||||||
|
currentSessionKey: string | null;
|
||||||
|
sessionMessageCount: number;
|
||||||
|
composerOverlayHeight: number;
|
||||||
|
// Id of the newest user message in the rendered timeline. When a send has
|
||||||
|
// armed the anchor, the next new id here becomes the anchored row.
|
||||||
|
lastUserMessageId: string | null;
|
||||||
|
onActiveTurnChange?: (turnId: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseChatTimelineScrollResult {
|
||||||
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
// The live scroll element, as state, so effects that must re-bind when the
|
||||||
|
// list remounts (session switch) can depend on it.
|
||||||
|
scrollNode: HTMLDivElement | null;
|
||||||
|
isPinned: boolean;
|
||||||
|
registerList: (list: TimelineListHandle | null) => void;
|
||||||
|
anchorMessageId: string | null;
|
||||||
|
onAnchorReady: (messageId: string, anchorIndex: number) => void;
|
||||||
|
onAnchorSizeChanged: (messageId: string) => void;
|
||||||
|
onIsAtEndChange: (isAtEnd: boolean) => void;
|
||||||
|
onManualNavigation: () => void;
|
||||||
|
onTimelineDataChange: () => void;
|
||||||
|
showScrollButton: boolean;
|
||||||
|
isFollowingProgrammatically: boolean;
|
||||||
|
goToBottom: (mode?: 'instant' | 'smooth') => void;
|
||||||
|
scrollToBottomOnSend: () => void;
|
||||||
|
notifyContentChange: (reason?: ContentChangeReason) => void;
|
||||||
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||||
|
saveSnapshotNow: () => void;
|
||||||
|
restoreSnapshot: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Showing the pill is debounced so it does not flash while a thread switch
|
||||||
|
// settles (the list reports isAtEnd=false until its initial end-scroll lands).
|
||||||
|
// Hiding is always immediate.
|
||||||
|
const SHOW_SCROLL_BUTTON_DELAY_MS = 150;
|
||||||
|
const SAVE_DEBOUNCE_MS = 150;
|
||||||
|
// The anchor scroll is animated; `scrollend` is the authoritative completion
|
||||||
|
// signal, and this bounds the wait for browsers that drop it.
|
||||||
|
const ANCHOR_SETTLE_FALLBACK_MS = 750;
|
||||||
|
// Re-running the anchor positioning while the list is still mounting rows.
|
||||||
|
const ANCHOR_POSITION_ATTEMPTS = 12;
|
||||||
|
// Anchor restores only correct sub-pixel drift; anything larger is the user or
|
||||||
|
// a genuine relayout and must not be undone.
|
||||||
|
const ANCHOR_RESTORE_TOLERANCE_PX = 2;
|
||||||
|
|
||||||
|
const NOOP = (): void => {};
|
||||||
|
|
||||||
|
export const useChatTimelineScroll = ({
|
||||||
|
currentSessionId,
|
||||||
|
currentSessionKey,
|
||||||
|
sessionMessageCount,
|
||||||
|
composerOverlayHeight,
|
||||||
|
lastUserMessageId,
|
||||||
|
onActiveTurnChange,
|
||||||
|
}: UseChatTimelineScrollOptions): UseChatTimelineScrollResult => {
|
||||||
|
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const listRef = React.useRef<TimelineListHandle | null>(null);
|
||||||
|
|
||||||
|
const [scrollNode, setScrollNode] = React.useState<HTMLDivElement | null>(null);
|
||||||
|
const [anchorMessageId, setAnchorMessageId] = React.useState<string | null>(null);
|
||||||
|
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
||||||
|
// "Pinned" is the live edge, which history pagination uses to decide whether
|
||||||
|
// it may load older pages without disturbing the read position.
|
||||||
|
const [isPinned, setIsPinned] = React.useState(true);
|
||||||
|
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
|
||||||
|
|
||||||
|
const modeRef = React.useRef<TimelineScrollMode>('following-end');
|
||||||
|
const isAtEndRef = React.useRef(true);
|
||||||
|
// Incremented by every real user gesture. Automatic movement is only valid
|
||||||
|
// while `liveFollowGenerationRef` still equals it.
|
||||||
|
const userGenerationRef = React.useRef(0);
|
||||||
|
const liveFollowGenerationRef = React.useRef<number | null>(0);
|
||||||
|
// Anchor lifecycle: armed on send → pending until the row exists → positioned
|
||||||
|
// while the animated scroll runs → settled once it has come to rest.
|
||||||
|
const armedForNextUserMessageRef = React.useRef(false);
|
||||||
|
const pendingAnchorRef = React.useRef<string | null>(null);
|
||||||
|
const positionedAnchorRef = React.useRef<string | null>(null);
|
||||||
|
const settledAnchorRef = React.useRef<string | null>(null);
|
||||||
|
const activeAnchorIndexRef = React.useRef<number | null>(null);
|
||||||
|
const pendingAnchorRestoreRef = React.useRef<{
|
||||||
|
readonly messageId: string;
|
||||||
|
readonly offset: number;
|
||||||
|
readonly userGeneration: number;
|
||||||
|
} | null>(null);
|
||||||
|
const anchorRestoreFrameRef = React.useRef<number | null>(null);
|
||||||
|
const showButtonTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const composerOverlayHeightRef = React.useRef(composerOverlayHeight);
|
||||||
|
composerOverlayHeightRef.current = composerOverlayHeight;
|
||||||
|
const sessionMessageCountRef = React.useRef(sessionMessageCount);
|
||||||
|
sessionMessageCountRef.current = sessionMessageCount;
|
||||||
|
const currentSessionIdRef = React.useRef(currentSessionId);
|
||||||
|
currentSessionIdRef.current = currentSessionId;
|
||||||
|
const currentSessionKeyRef = React.useRef(currentSessionKey);
|
||||||
|
currentSessionKeyRef.current = currentSessionKey;
|
||||||
|
|
||||||
|
const updateViewportAnchor = useViewportStore((state) => state.updateViewportAnchor);
|
||||||
|
|
||||||
|
const cancelShowButtonTimer = React.useCallback(() => {
|
||||||
|
if (showButtonTimerRef.current !== null) {
|
||||||
|
clearTimeout(showButtonTimerRef.current);
|
||||||
|
showButtonTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hideScrollButton = React.useCallback(() => {
|
||||||
|
cancelShowButtonTimer();
|
||||||
|
setShowScrollButton(false);
|
||||||
|
}, [cancelShowButtonTimer]);
|
||||||
|
|
||||||
|
const scheduleShowScrollButton = React.useCallback(() => {
|
||||||
|
if (showButtonTimerRef.current !== null) return;
|
||||||
|
showButtonTimerRef.current = setTimeout(() => {
|
||||||
|
showButtonTimerRef.current = null;
|
||||||
|
setShowScrollButton(true);
|
||||||
|
}, SHOW_SCROLL_BUTTON_DELAY_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAnchor = React.useCallback(() => {
|
||||||
|
armedForNextUserMessageRef.current = false;
|
||||||
|
pendingAnchorRef.current = null;
|
||||||
|
positionedAnchorRef.current = null;
|
||||||
|
settledAnchorRef.current = null;
|
||||||
|
activeAnchorIndexRef.current = null;
|
||||||
|
pendingAnchorRestoreRef.current = null;
|
||||||
|
if (anchorRestoreFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(anchorRestoreFrameRef.current);
|
||||||
|
anchorRestoreFrameRef.current = null;
|
||||||
|
}
|
||||||
|
setAnchorMessageId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A real gesture: stop every automatic movement until the user opts back in.
|
||||||
|
const onManualNavigation = React.useCallback(() => {
|
||||||
|
userGenerationRef.current += 1;
|
||||||
|
modeRef.current = 'free-scrolling';
|
||||||
|
liveFollowGenerationRef.current = null;
|
||||||
|
clearAnchor();
|
||||||
|
}, [clearAnchor]);
|
||||||
|
|
||||||
|
const isLiveFollowActive = React.useCallback(() => (
|
||||||
|
liveFollowGenerationRef.current === userGenerationRef.current
|
||||||
|
), []);
|
||||||
|
|
||||||
|
// ── snapshot persistence ────────────────────────────────────────────────
|
||||||
|
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
||||||
|
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const flushSave = React.useCallback(() => {
|
||||||
|
if (saveTimerRef.current !== null) {
|
||||||
|
clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
const pending = pendingSaveRef.current;
|
||||||
|
if (!pending) return;
|
||||||
|
const container = scrollRef.current;
|
||||||
|
if (!container) {
|
||||||
|
pendingSaveRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateViewportAnchor(pending.sessionId, pending.anchor, {
|
||||||
|
scrollTop: container.scrollTop,
|
||||||
|
scrollHeight: container.scrollHeight,
|
||||||
|
clientHeight: container.clientHeight,
|
||||||
|
});
|
||||||
|
pendingSaveRef.current = null;
|
||||||
|
}, [updateViewportAnchor]);
|
||||||
|
|
||||||
|
const queueSave = React.useCallback(() => {
|
||||||
|
const sessionId = currentSessionIdRef.current;
|
||||||
|
if (!sessionId) return;
|
||||||
|
const container = scrollRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||||
|
const anchorRatio = scrollHeight > 0
|
||||||
|
? (scrollTop + clientHeight / 2) / scrollHeight
|
||||||
|
: 0;
|
||||||
|
const anchor = Math.floor(anchorRatio * sessionMessageCountRef.current);
|
||||||
|
|
||||||
|
pendingSaveRef.current = { sessionId, anchor };
|
||||||
|
if (saveTimerRef.current !== null) return;
|
||||||
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
saveTimerRef.current = null;
|
||||||
|
flushSave();
|
||||||
|
}, SAVE_DEBOUNCE_MS);
|
||||||
|
}, [flushSave]);
|
||||||
|
|
||||||
|
const saveSnapshotNow = React.useCallback(() => {
|
||||||
|
flushSave();
|
||||||
|
}, [flushSave]);
|
||||||
|
|
||||||
|
// ── scroll commands ─────────────────────────────────────────────────────
|
||||||
|
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
|
||||||
|
isAtEndRef.current = true;
|
||||||
|
setIsPinned(true);
|
||||||
|
modeRef.current = 'following-end';
|
||||||
|
// Returning to the end is an explicit opt back IN to live follow.
|
||||||
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||||
|
clearAnchor();
|
||||||
|
hideScrollButton();
|
||||||
|
void listRef.current?.scrollToEnd({ animated: mode === 'smooth' });
|
||||||
|
}, [clearAnchor, hideScrollButton]);
|
||||||
|
|
||||||
|
// Sending arms the anchor. The message id is not known here (the optimistic
|
||||||
|
// row is created by the store), so the next new user message id claims it.
|
||||||
|
const scrollToBottomOnSend = React.useCallback(() => {
|
||||||
|
isAtEndRef.current = true;
|
||||||
|
modeRef.current = 'anchoring-new-turn';
|
||||||
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||||
|
armedForNextUserMessageRef.current = true;
|
||||||
|
pendingAnchorRef.current = null;
|
||||||
|
positionedAnchorRef.current = null;
|
||||||
|
settledAnchorRef.current = null;
|
||||||
|
activeAnchorIndexRef.current = null;
|
||||||
|
hideScrollButton();
|
||||||
|
}, [hideScrollButton]);
|
||||||
|
|
||||||
|
// Claim the anchor as soon as the sent row exists in the timeline.
|
||||||
|
const lastArmedUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const previous = lastArmedUserMessageIdRef.current;
|
||||||
|
lastArmedUserMessageIdRef.current = lastUserMessageId;
|
||||||
|
if (!armedForNextUserMessageRef.current) return;
|
||||||
|
if (!lastUserMessageId || lastUserMessageId === previous) return;
|
||||||
|
armedForNextUserMessageRef.current = false;
|
||||||
|
pendingAnchorRef.current = lastUserMessageId;
|
||||||
|
setAnchorMessageId(lastUserMessageId);
|
||||||
|
}, [lastUserMessageId]);
|
||||||
|
|
||||||
|
const restoreSnapshot = React.useCallback(async (): Promise<boolean> => {
|
||||||
|
const sessionKey = currentSessionKeyRef.current;
|
||||||
|
if (!sessionKey) return false;
|
||||||
|
|
||||||
|
// Entering a session always returns to the live edge. Late async growth
|
||||||
|
// is handled by the list staying at the end, not by a timed hold.
|
||||||
|
isAtEndRef.current = true;
|
||||||
|
modeRef.current = 'following-end';
|
||||||
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||||
|
clearAnchor();
|
||||||
|
hideScrollButton();
|
||||||
|
void listRef.current?.scrollToEnd({ animated: false });
|
||||||
|
return false;
|
||||||
|
}, [clearAnchor, hideScrollButton]);
|
||||||
|
|
||||||
|
// ── list callbacks ──────────────────────────────────────────────────────
|
||||||
|
const registerList = React.useCallback((list: TimelineListHandle | null) => {
|
||||||
|
listRef.current = list;
|
||||||
|
const node = (list?.getScrollableNode() as HTMLDivElement | null) ?? null;
|
||||||
|
scrollRef.current = node;
|
||||||
|
setScrollNode(node);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onIsAtEndChange = React.useCallback((isAtEnd: boolean) => {
|
||||||
|
// While an automatic movement owns the viewport, leaving the end is our
|
||||||
|
// own doing (the anchored turn parks mid-timeline) — not a reason to
|
||||||
|
// offer the user a scroll-to-bottom pill.
|
||||||
|
if (!isAtEnd && isLiveFollowActive()) {
|
||||||
|
hideScrollButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAtEndRef.current === isAtEnd) return;
|
||||||
|
isAtEndRef.current = isAtEnd;
|
||||||
|
setIsPinned(isAtEnd);
|
||||||
|
if (isAtEnd) {
|
||||||
|
modeRef.current = 'following-end';
|
||||||
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||||
|
hideScrollButton();
|
||||||
|
} else {
|
||||||
|
modeRef.current = 'free-scrolling';
|
||||||
|
liveFollowGenerationRef.current = null;
|
||||||
|
scheduleShowScrollButton();
|
||||||
|
}
|
||||||
|
queueSave();
|
||||||
|
}, [hideScrollButton, isLiveFollowActive, queueSave, scheduleShowScrollButton]);
|
||||||
|
|
||||||
|
// Park the anchored row near the top once the list has measured it.
|
||||||
|
const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => {
|
||||||
|
if (pendingAnchorRef.current === messageId) {
|
||||||
|
pendingAnchorRef.current = null;
|
||||||
|
}
|
||||||
|
activeAnchorIndexRef.current = anchorIndex;
|
||||||
|
if (positionedAnchorRef.current === messageId) return;
|
||||||
|
positionedAnchorRef.current = messageId;
|
||||||
|
settledAnchorRef.current = null;
|
||||||
|
|
||||||
|
const positionAnchor = (remainingAttempts: number) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (positionedAnchorRef.current !== messageId) return;
|
||||||
|
const list = listRef.current;
|
||||||
|
if (!list) {
|
||||||
|
if (remainingAttempts > 0) positionAnchor(remainingAttempts - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scrollNode = list.getScrollableNode();
|
||||||
|
if (!scrollNode) {
|
||||||
|
if (remainingAttempts > 0) positionAnchor(remainingAttempts - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let finished = false;
|
||||||
|
const finishPositioning = () => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
clearTimeout(fallbackTimer);
|
||||||
|
scrollNode.removeEventListener('scrollend', finishPositioning);
|
||||||
|
if (positionedAnchorRef.current !== messageId) return;
|
||||||
|
// Re-assert the resting offset without animation so the
|
||||||
|
// smooth scroll's own momentum cannot drift past it.
|
||||||
|
const scrollOffset = list.getState().scroll;
|
||||||
|
void list.scrollToOffset({ offset: scrollOffset, animated: false });
|
||||||
|
settledAnchorRef.current = messageId;
|
||||||
|
};
|
||||||
|
const fallbackTimer = setTimeout(finishPositioning, ANCHOR_SETTLE_FALLBACK_MS);
|
||||||
|
scrollNode.addEventListener('scrollend', finishPositioning, { once: true });
|
||||||
|
|
||||||
|
void list.scrollToIndex({
|
||||||
|
index: anchorIndex,
|
||||||
|
animated: true,
|
||||||
|
viewPosition: 0,
|
||||||
|
viewOffset: CHAT_LIST_ANCHOR_OFFSET,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(() => positionAnchor(ANCHOR_POSITION_ATTEMPTS));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The anchored row can still change height after it settles (an image
|
||||||
|
// decoding, a code block highlighting). Hold the resting offset, but only
|
||||||
|
// against sub-pixel drift and only while the user has not taken over.
|
||||||
|
const onAnchorSizeChanged = React.useCallback((messageId: string) => {
|
||||||
|
if (settledAnchorRef.current !== messageId) return;
|
||||||
|
if (isLiveFollowActive()) return;
|
||||||
|
const scrollOffset = listRef.current?.getState().scroll;
|
||||||
|
if (scrollOffset === undefined) return;
|
||||||
|
|
||||||
|
if (pendingAnchorRestoreRef.current === null) {
|
||||||
|
pendingAnchorRestoreRef.current = {
|
||||||
|
messageId,
|
||||||
|
offset: scrollOffset,
|
||||||
|
userGeneration: userGenerationRef.current,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (anchorRestoreFrameRef.current !== null) return;
|
||||||
|
|
||||||
|
anchorRestoreFrameRef.current = requestAnimationFrame(() => {
|
||||||
|
anchorRestoreFrameRef.current = null;
|
||||||
|
const pending = pendingAnchorRestoreRef.current;
|
||||||
|
pendingAnchorRestoreRef.current = null;
|
||||||
|
if (
|
||||||
|
!pending
|
||||||
|
|| settledAnchorRef.current !== pending.messageId
|
||||||
|
|| pending.userGeneration !== userGenerationRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = listRef.current;
|
||||||
|
const currentOffset = list?.getState().scroll;
|
||||||
|
if (
|
||||||
|
typeof currentOffset === 'number'
|
||||||
|
&& Math.abs(currentOffset - pending.offset) <= ANCHOR_RESTORE_TOLERANCE_PX
|
||||||
|
) {
|
||||||
|
void list?.scrollToOffset({ offset: pending.offset, animated: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [isLiveFollowActive]);
|
||||||
|
|
||||||
|
// Whether the real rows (ignoring any reserved anchored end space) are tall
|
||||||
|
// enough to scroll. Without this, entering a short session would scroll into
|
||||||
|
// the reserved space and strand the content above the viewport.
|
||||||
|
const realContentOverflowsViewport = React.useCallback((list: TimelineListHandle): boolean => {
|
||||||
|
const state = list.getState();
|
||||||
|
if (state.data.length === 0) return false;
|
||||||
|
|
||||||
|
const lastIndex = state.data.length - 1;
|
||||||
|
const lastTop = state.positionAtIndex(lastIndex);
|
||||||
|
const lastHeight = state.sizeAtIndex(lastIndex);
|
||||||
|
if (
|
||||||
|
typeof lastTop !== 'number'
|
||||||
|
|| typeof lastHeight !== 'number'
|
||||||
|
|| !Number.isFinite(lastTop)
|
||||||
|
|| !Number.isFinite(lastHeight)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realContentBottom = lastTop + Math.max(1, lastHeight);
|
||||||
|
const visibleScrollLength = Math.max(
|
||||||
|
0,
|
||||||
|
state.scrollLength - composerOverlayHeightRef.current - CHAT_LIST_ANCHOR_OFFSET,
|
||||||
|
);
|
||||||
|
return realContentBottom > visibleScrollLength;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// One deterministic correction per data change, two frames out so the list
|
||||||
|
// has measured the new rows. Nothing runs while the user owns the scroll.
|
||||||
|
const dataChangeFramesRef = React.useRef<{ first: number | null; second: number | null }>({
|
||||||
|
first: null,
|
||||||
|
second: null,
|
||||||
|
});
|
||||||
|
const onTimelineDataChange = React.useCallback(() => {
|
||||||
|
if (!isLiveFollowActive()) return;
|
||||||
|
|
||||||
|
const frames = dataChangeFramesRef.current;
|
||||||
|
if (frames.first !== null) cancelAnimationFrame(frames.first);
|
||||||
|
if (frames.second !== null) cancelAnimationFrame(frames.second);
|
||||||
|
|
||||||
|
frames.first = requestAnimationFrame(() => {
|
||||||
|
frames.first = null;
|
||||||
|
frames.second = requestAnimationFrame(() => {
|
||||||
|
frames.second = null;
|
||||||
|
if (!isLiveFollowActive()) return;
|
||||||
|
// An anchor that exists but has not come to rest yet owns the
|
||||||
|
// viewport; correcting now would fight its animation.
|
||||||
|
if (pendingAnchorRef.current !== null) return;
|
||||||
|
if (
|
||||||
|
positionedAnchorRef.current !== null
|
||||||
|
&& settledAnchorRef.current !== positionedAnchorRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = listRef.current;
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
if (modeRef.current === 'anchoring-new-turn') {
|
||||||
|
const anchorIndex = activeAnchorIndexRef.current;
|
||||||
|
if (anchorIndex === null) return;
|
||||||
|
const metrics = getAnchoredTurnMetrics({
|
||||||
|
state: list.getState(),
|
||||||
|
anchorIndex,
|
||||||
|
composerOverlayHeight: composerOverlayHeightRef.current,
|
||||||
|
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
|
||||||
|
});
|
||||||
|
// The turn still fits: leave the viewport exactly where the
|
||||||
|
// user is reading.
|
||||||
|
if (!metrics || metrics.scrollDeltaToRevealEnd <= 1) return;
|
||||||
|
void list.scrollToOffset({
|
||||||
|
offset: list.getState().scroll + metrics.scrollDeltaToRevealEnd,
|
||||||
|
animated: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeRef.current !== 'following-end') return;
|
||||||
|
if (!realContentOverflowsViewport(list)) return;
|
||||||
|
void list.scrollToEnd({ animated: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [isLiveFollowActive, realContentOverflowsViewport]);
|
||||||
|
|
||||||
|
// ── gesture opt-out ─────────────────────────────────────────────────────
|
||||||
|
const onManualNavigationRef = React.useRef(onManualNavigation);
|
||||||
|
onManualNavigationRef.current = onManualNavigation;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!scrollNode) return;
|
||||||
|
|
||||||
|
const handleGesture = () => {
|
||||||
|
onManualNavigationRef.current();
|
||||||
|
};
|
||||||
|
const handleScroll = () => {
|
||||||
|
queueSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollNode.addEventListener('wheel', handleGesture, { passive: true });
|
||||||
|
scrollNode.addEventListener('touchmove', handleGesture, { passive: true });
|
||||||
|
scrollNode.addEventListener('pointerdown', handleGesture, { passive: true });
|
||||||
|
scrollNode.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollNode.removeEventListener('wheel', handleGesture);
|
||||||
|
scrollNode.removeEventListener('touchmove', handleGesture);
|
||||||
|
scrollNode.removeEventListener('pointerdown', handleGesture);
|
||||||
|
scrollNode.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, [queueSave, scrollNode]);
|
||||||
|
|
||||||
|
// ── session lifecycle ───────────────────────────────────────────────────
|
||||||
|
const lastSessionKeyRef = React.useRef<string | null>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!currentSessionId || !currentSessionKey || currentSessionKey === lastSessionKeyRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastSessionKeyRef.current = currentSessionKey;
|
||||||
|
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
||||||
|
// Persist the outgoing session's position before the new one takes over.
|
||||||
|
flushSave();
|
||||||
|
isAtEndRef.current = true;
|
||||||
|
modeRef.current = 'following-end';
|
||||||
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||||
|
clearAnchor();
|
||||||
|
hideScrollButton();
|
||||||
|
}, [clearAnchor, currentSessionId, currentSessionKey, flushSave, hideScrollButton]);
|
||||||
|
|
||||||
|
// Suppress the overlay scrollbar thumb while automatic movement owns the
|
||||||
|
// scroll position, so it does not jump on each correction.
|
||||||
|
React.useEffect(() => {
|
||||||
|
setIsFollowingProgrammatically(!showScrollButton && anchorMessageId === null);
|
||||||
|
}, [anchorMessageId, showScrollButton]);
|
||||||
|
|
||||||
|
React.useEffect(() => () => {
|
||||||
|
cancelShowButtonTimer();
|
||||||
|
if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current);
|
||||||
|
if (anchorRestoreFrameRef.current !== null) cancelAnimationFrame(anchorRestoreFrameRef.current);
|
||||||
|
const frames = dataChangeFramesRef.current;
|
||||||
|
if (frames.first !== null) cancelAnimationFrame(frames.first);
|
||||||
|
if (frames.second !== null) cancelAnimationFrame(frames.second);
|
||||||
|
}, [cancelShowButtonTimer]);
|
||||||
|
|
||||||
|
// ── active-turn spy ─────────────────────────────────────────────────────
|
||||||
|
// Reads turn positions straight from the DOM, so it is unaffected by which
|
||||||
|
// list implementation owns the container. Rows mounting and unmounting
|
||||||
|
// during virtualized scrolling are tracked through the mutation observer.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!onActiveTurnChange) return;
|
||||||
|
const container = scrollNode;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let lastActiveTurnId: string | null = null;
|
||||||
|
const spy = createScrollSpy({
|
||||||
|
onActive: (turnId) => {
|
||||||
|
if (turnId === lastActiveTurnId) return;
|
||||||
|
lastActiveTurnId = turnId;
|
||||||
|
onActiveTurnChange(turnId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
spy.setContainer(container);
|
||||||
|
|
||||||
|
const elementByTurnId = new Map<string, HTMLElement>();
|
||||||
|
const registerTurnNode = (node: HTMLElement) => {
|
||||||
|
const turnId = node.dataset.turnId;
|
||||||
|
if (!turnId) return false;
|
||||||
|
elementByTurnId.set(turnId, node);
|
||||||
|
spy.register(node, turnId);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const unregisterTurnNode = (node: HTMLElement) => {
|
||||||
|
const turnId = node.dataset.turnId;
|
||||||
|
if (!turnId) return false;
|
||||||
|
if (elementByTurnId.get(turnId) !== node) return false;
|
||||||
|
elementByTurnId.delete(turnId);
|
||||||
|
spy.unregister(turnId);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const collectTurnNodes = (node: Node): HTMLElement[] => {
|
||||||
|
if (!(node instanceof HTMLElement)) return [];
|
||||||
|
const collected: HTMLElement[] = [];
|
||||||
|
if (node.matches('[data-turn-id]')) collected.push(node);
|
||||||
|
node.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((el) => collected.push(el));
|
||||||
|
return collected;
|
||||||
|
};
|
||||||
|
|
||||||
|
container.querySelectorAll<HTMLElement>('[data-turn-id]').forEach(registerTurnNode);
|
||||||
|
spy.markDirty();
|
||||||
|
|
||||||
|
const mutationObserver = new MutationObserver((records) => {
|
||||||
|
let changed = false;
|
||||||
|
records.forEach((record) => {
|
||||||
|
record.removedNodes.forEach((node) => {
|
||||||
|
collectTurnNodes(node).forEach((turnNode) => {
|
||||||
|
if (unregisterTurnNode(turnNode)) changed = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
record.addedNodes.forEach((node) => {
|
||||||
|
collectTurnNodes(node).forEach((turnNode) => {
|
||||||
|
if (registerTurnNode(turnNode)) changed = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (changed) spy.markDirty();
|
||||||
|
});
|
||||||
|
mutationObserver.observe(container, { subtree: true, childList: true });
|
||||||
|
|
||||||
|
const onScroll = () => spy.onScroll();
|
||||||
|
container.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('scroll', onScroll);
|
||||||
|
mutationObserver.disconnect();
|
||||||
|
spy.destroy();
|
||||||
|
};
|
||||||
|
}, [onActiveTurnChange, scrollNode]);
|
||||||
|
|
||||||
|
// ── inert compatibility surface ─────────────────────────────────────────
|
||||||
|
const stableAnimationHandlers = React.useMemo<AnimationHandlers>(() => ({
|
||||||
|
onChunk: NOOP,
|
||||||
|
onComplete: NOOP,
|
||||||
|
onStreamingCandidate: NOOP,
|
||||||
|
onAnimationStart: NOOP,
|
||||||
|
onReservationCancelled: NOOP,
|
||||||
|
onReasoningBlock: NOOP,
|
||||||
|
onAnimatedHeightChange: NOOP,
|
||||||
|
}), []);
|
||||||
|
const getAnimationHandlers = React.useCallback(() => stableAnimationHandlers, [stableAnimationHandlers]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scrollRef,
|
||||||
|
scrollNode,
|
||||||
|
isPinned,
|
||||||
|
registerList,
|
||||||
|
anchorMessageId,
|
||||||
|
onAnchorReady,
|
||||||
|
onAnchorSizeChanged,
|
||||||
|
onIsAtEndChange,
|
||||||
|
onManualNavigation,
|
||||||
|
onTimelineDataChange,
|
||||||
|
showScrollButton,
|
||||||
|
isFollowingProgrammatically,
|
||||||
|
goToBottom,
|
||||||
|
scrollToBottomOnSend,
|
||||||
|
notifyContentChange: NOOP,
|
||||||
|
getAnimationHandlers,
|
||||||
|
saveSnapshotNow,
|
||||||
|
restoreSnapshot,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user