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:
@@ -60,24 +60,6 @@ export interface UseChatTimelineControllerResult {
|
||||
|
||||
const TURN_MODEL_CACHE_MAX = 30
|
||||
const HISTORY_SCROLL_THRESHOLD = 200
|
||||
// On touch surfaces the user can drag continuously toward the top, and
|
||||
// loadEarlier is an async (network) fetch. A 200px lead is enough on desktop
|
||||
// (wheel + fast render) but the finger can outrun an in-flight fetch on mobile
|
||||
// and hit the very top before history lands. Give touch a much larger,
|
||||
// viewport-relative head start so the fetch completes before the top is
|
||||
// reached, regardless of how fast the user drags.
|
||||
const MOBILE_HISTORY_SCROLL_THRESHOLD_MIN = 1200
|
||||
const MOBILE_HISTORY_SCROLL_VIEWPORT_FACTOR = 2
|
||||
|
||||
const resolveHistoryScrollThreshold = (clientHeight: number): number => {
|
||||
if (!isMobileSurfaceRuntime()) {
|
||||
return HISTORY_SCROLL_THRESHOLD
|
||||
}
|
||||
return Math.max(
|
||||
MOBILE_HISTORY_SCROLL_THRESHOLD_MIN,
|
||||
clientHeight * MOBILE_HISTORY_SCROLL_VIEWPORT_FACTOR,
|
||||
)
|
||||
}
|
||||
const VSCODE_TURN_MODEL_CACHE_MAX = 4
|
||||
const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
||||
const MOBILE_TURN_MODEL_CACHE_MAX = 4
|
||||
@@ -544,7 +526,10 @@ export const useChatTimelineController = ({
|
||||
return true;
|
||||
};
|
||||
|
||||
if (isMobileSurfaceRuntime() && heightDelta > 0) {
|
||||
// Non-virtualized mobile list only: fight iOS momentum manually.
|
||||
// The virtualized mobile list (tanstack) defers prepend adjustments
|
||||
// through touch/momentum in core, so manual writes would double up.
|
||||
if (isMobileSurfaceRuntime() && !historyVirtualized && heightDelta > 0) {
|
||||
setScrollTopDefeatingMomentum(container, snap.top + heightDelta);
|
||||
updateTracking();
|
||||
return;
|
||||
@@ -554,13 +539,21 @@ export const useChatTimelineController = ({
|
||||
// restoreViewportAnchor which falls back to virtualizer-aware
|
||||
// scrollHistoryIndexIntoView when the element is not in the DOM.
|
||||
// Note: an unchanged scrollTop after restore is NOT a failure here —
|
||||
// the virtualized desktop list runs with virtua `shift`, which
|
||||
// compensates the prepend internally, so staying near snap.top is
|
||||
// the correct outcome.
|
||||
// the virtualized list compensates the prepend internally, so
|
||||
// staying near snap.top is the correct outcome.
|
||||
if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) {
|
||||
// Fallback: height-delta compensation
|
||||
applyHeightDelta();
|
||||
}
|
||||
if (historyVirtualized && snap.anchor && isMobileSurfaceRuntime()) {
|
||||
// Mobile only: freshly prepended rows keep re-measuring for a
|
||||
// few frames and each pass can shift content, so hold the
|
||||
// anchor until it settles. Desktop must NOT run this — wheel
|
||||
// scrolling during the hold would fight the re-assertions and
|
||||
// read as a frozen scroll; the virtualizer's own anchoring is
|
||||
// enough there.
|
||||
messageListRef.current?.holdViewportAnchor(snap.anchor);
|
||||
}
|
||||
} else if (isPrepend && prev && !historyVirtualized) {
|
||||
// Released viewport: preserve the read position by compensating for the
|
||||
// exact height the prepend added above, with no intermediate frame for
|
||||
@@ -690,10 +683,16 @@ export const useChatTimelineController = ({
|
||||
}, [beginHistoryInteraction, fetchOlderHistory, releaseAutoFollow, settleHistoryInteraction]);
|
||||
|
||||
const handleHistoryScroll = React.useCallback(() => {
|
||||
// Mobile never loads history from scroll position: any prepend racing
|
||||
// an active touch gesture can be hijacked by the native scroll
|
||||
// animation. The user scrolls to the natural top and taps an explicit
|
||||
// "load older" button instead — the insert then happens from a resting
|
||||
// state, which is fully deterministic.
|
||||
if (isMobileSurfaceRuntime()) return;
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
if (isPinnedRef.current) return;
|
||||
if (container.scrollTop >= resolveHistoryScrollThreshold(container.clientHeight)) return;
|
||||
if (container.scrollTop >= HISTORY_SCROLL_THRESHOLD) return;
|
||||
if (!historySignalsRef.current.canLoadEarlier) return;
|
||||
if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user