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:
Bohdan Triapitsyn
2026-07-03 18:43:40 +03:00
parent d71aec54db
commit 2bce38cfbb
18 changed files with 458 additions and 145 deletions
@@ -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