feat(chat): migrate history list to @tanstack/react-virtual with deterministic mobile history loading
- Replace virtua with @tanstack/react-virtual for chat history on all surfaces: bottom anchoring (anchorTo: end), key-stable prepend preservation, and native iOS touch/momentum deferral live in the core - Patch virtual-core to clamp the render range to real scroll bounds during transient adjustments - Rows render in normal flow inside a translated wrapper so sticky user headers keep working; measurement snapshots cached per session - Pre-write container height in scrollToFn so the browser cannot clamp anchor corrections to the stale height; hold the prepend anchor for up to 180 frames on mobile while fresh rows settle (cancelled by user input; desktop relies on core anchoring alone) - Adaptive row-size estimate from per-session measured averages; disable reveal fade-in for virtualized history rows - Mobile loads older history only through an explicit localized top button: no scroll-position trigger and no post-mount background prepend, so every insert happens from a resting state; a quiet-window hold defers any stray prepend commit while a touch gesture is active - Desktop/VS Code keep the seamless scroll-up trigger and progressive background prepend
This commit is contained in:
@@ -46,6 +46,7 @@ import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-pre
|
||||
import { getSessionMaterializationStatus } from '@/sync/materialization';
|
||||
import { usePlanDetection } from '@/hooks/usePlanDetection';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
|
||||
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
||||
@@ -157,6 +158,8 @@ type ChatViewportProps = {
|
||||
sessionQuestions: QuestionRequest[];
|
||||
sessionPermissions: PermissionRequest[];
|
||||
isProgrammaticFollowActive: boolean;
|
||||
showLoadOlderButton: boolean;
|
||||
onLoadOlder: () => void;
|
||||
};
|
||||
|
||||
const ChatViewport = React.memo(({
|
||||
@@ -181,7 +184,10 @@ const ChatViewport = React.memo(({
|
||||
sessionQuestions,
|
||||
sessionPermissions,
|
||||
isProgrammaticFollowActive,
|
||||
showLoadOlderButton,
|
||||
onLoadOlder,
|
||||
}: ChatViewportProps) => {
|
||||
const { t } = useI18n();
|
||||
const focusScrollContainer = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
if (event.defaultPrevented || shouldIgnoreChatNavigationTarget(event.target)) {
|
||||
return;
|
||||
@@ -218,6 +224,21 @@ const ChatViewport = React.memo(({
|
||||
data-scrollbar="chat"
|
||||
>
|
||||
<div className="relative z-0 min-h-full">
|
||||
{showLoadOlderButton && (
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onLoadOlder}
|
||||
disabled={isLoadingOlder}
|
||||
>
|
||||
{isLoadingOlder && (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
)}
|
||||
{t('chat.history.loadOlder')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<MessageList
|
||||
ref={messageListRef}
|
||||
sessionKey={currentSessionId}
|
||||
@@ -277,7 +298,9 @@ const ChatViewport = React.memo(({
|
||||
&& prev.scrollToBottom === next.scrollToBottom
|
||||
&& prev.sessionQuestions === next.sessionQuestions
|
||||
&& prev.sessionPermissions === next.sessionPermissions
|
||||
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive;
|
||||
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
|
||||
&& prev.showLoadOlderButton === next.showLoadOlderButton
|
||||
&& prev.onLoadOlder === next.onLoadOlder;
|
||||
});
|
||||
|
||||
ChatViewport.displayName = 'ChatViewport';
|
||||
@@ -599,6 +622,14 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
const resumeToLatestInstant = React.useCallback(() => {
|
||||
goToBottom('instant');
|
||||
}, [goToBottom]);
|
||||
// Mobile loads older history via an explicit top button instead of a
|
||||
// scroll-position trigger (see handleHistoryScroll in the controller).
|
||||
const showLoadOlderButton = isMobileSurfaceRuntime()
|
||||
&& timelineController.historySignals.canLoadEarlier;
|
||||
const timelineLoadEarlier = timelineController.loadEarlier;
|
||||
const handleLoadOlderClick = React.useCallback(() => {
|
||||
void timelineLoadEarlier({ userInitiated: true });
|
||||
}, [timelineLoadEarlier]);
|
||||
|
||||
React.useEffect(() => {
|
||||
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
||||
@@ -918,6 +949,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
sessionQuestions={sessionQuestions}
|
||||
sessionPermissions={sessionPermissions}
|
||||
isProgrammaticFollowActive={isFollowingProgrammatically}
|
||||
showLoadOlderButton={showLoadOlderButton}
|
||||
onLoadOlder={handleLoadOlderClick}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user