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
+12 -4
View File
@@ -97,7 +97,7 @@
},
"packages/electron": {
"name": "@openchamber/electron",
"version": "1.13.8",
"version": "1.13.9",
"dependencies": {
"@openchamber/web": "workspace:*",
"electron-context-menu": "^4.1.2",
@@ -133,7 +133,7 @@
},
"packages/ui": {
"name": "@openchamber/ui",
"version": "1.13.8",
"version": "1.13.9",
"dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0",
"@base-ui/react": "^1.4.0",
@@ -171,6 +171,7 @@
"@pierre/diffs": "1.3.0-beta.6",
"@replit/codemirror-vim": "^6.3.0",
"@simplewebauthn/browser": "13.3.0",
"@tanstack/react-virtual": "3.14.5",
"@xenova/transformers": "^2.17.2",
"@zumer/snapdom": "^2.12.0",
"beautiful-mermaid": "^1.1.3",
@@ -236,7 +237,7 @@
},
"packages/vscode": {
"name": "openchamber",
"version": "1.13.8",
"version": "1.13.9",
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "1.17.12",
@@ -259,7 +260,7 @@
},
"packages/web": {
"name": "@openchamber/web",
"version": "1.13.8",
"version": "1.13.9",
"bin": {
"openchamber": "./bin/cli.js",
},
@@ -345,6 +346,9 @@
},
},
},
"patchedDependencies": {
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
},
"overrides": {
"@codemirror/language": "6.12.2",
"@codemirror/view": "6.39.13",
@@ -1296,6 +1300,10 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.5", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="],
"@textlint/ast-node-types": ["@textlint/ast-node-types@15.5.2", "", {}, "sha512-fCaOxoup5LIyBEo7R1oYWE7V4bSX0KQeHh66twon9e9usaLE3ijgF8QjYsR6joCssdeCHVd0wHm7ppsEyTr6vg=="],
"@textlint/linter-formatter": ["@textlint/linter-formatter@15.5.2", "", { "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", "@textlint/module-interop": "15.5.2", "@textlint/resolver": "15.5.2", "@textlint/types": "15.5.2", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", "lodash": "^4.17.23", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "table": "^6.9.0", "text-table": "^0.2.0" } }, "sha512-jAw7jWM8+wU9cG6Uu31jGyD1B+PAVePCvnPKC/oov+2iBPKk3ao30zc/Itmi7FvXo4oPaL9PmzPPQhyniPVgVg=="],
+3
View File
@@ -174,5 +174,8 @@
"typescript": "~5.9.0",
"typescript-eslint": "^8.39.1",
"vite": "^7.1.2"
},
"patchedDependencies": {
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
}
}
+1
View File
@@ -47,6 +47,7 @@
"@pierre/diffs": "1.3.0-beta.6",
"@replit/codemirror-vim": "^6.3.0",
"@simplewebauthn/browser": "13.3.0",
"@tanstack/react-virtual": "3.14.5",
"@xenova/transformers": "^2.17.2",
"@zumer/snapdom": "^2.12.0",
"beautiful-mermaid": "^1.1.3",
@@ -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
+331 -112
View File
@@ -1,6 +1,6 @@
import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
import { Virtualizer, type CacheSnapshot, type VirtualizerHandle } from 'virtua';
import { elementScroll, useVirtualizer as useTanstackVirtualizer, type ReactVirtualizer, type VirtualItem } from '@tanstack/react-virtual';
import ChatMessage from './ChatMessage';
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
@@ -18,21 +18,12 @@ import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
import type { StreamPhase } from './message/types';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useSessionParts } from '@/sync/sync-context';
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = [];
const EMPTY_UNGROUPED_MESSAGE_IDS = new Set<string>();
const MESSAGE_LIST_BUFFER_SIZE = 900;
// Touch surfaces fling-scroll natively and dispatch scroll events less often
// than the virtualizer can repaint, so a desktop-sized buffer leaves blank gaps
// during momentum that only fill once measurement catches up. A larger overscan
// keeps more rows mounted around the viewport so fast flings stay populated.
const MOBILE_MESSAGE_LIST_BUFFER_SIZE = 2400;
const resolveMessageListBufferSize = (): number => (
isMobileSurfaceRuntime() ? MOBILE_MESSAGE_LIST_BUFFER_SIZE : MESSAGE_LIST_BUFFER_SIZE
);
const TIMELINE_CACHE_LIMIT = 16;
const sameKeys = (a: readonly string[] | undefined, b: readonly string[] | undefined): boolean => {
@@ -42,28 +33,81 @@ const sameKeys = (a: readonly string[] | undefined, b: readonly string[] | undef
return a.every((key, index) => key === b[index]);
};
const timelineCache = new Map<string, { keys: readonly string[]; cache: CacheSnapshot }>();
// --- History virtualization (@tanstack/react-virtual) ----------------------
// The history list virtualizes with @tanstack/react-virtual on all surfaces:
// its core has bottom anchoring (anchorTo: 'end'), key-stable prepend
// preservation, and native iOS touch/momentum deferral for scroll
// adjustments — the failure modes that historically forced virtua off on
// mobile and required manual prepend compensation on desktop.
type TanstackVirtualizerInstance = ReactVirtualizer<HTMLDivElement, HTMLDivElement>;
type HistoryEngine = 'none' | 'tanstack';
const readTimelineCache = (sessionKey: string, keys: readonly string[]): CacheSnapshot | undefined => {
const entry = timelineCache.get(sessionKey);
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 (upstream parity): 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_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;
// Quiet-window prepend on mobile: while a touch drag or momentum scroll is
// active, iOS owns the scroll position and ANY geometry change above the
// viewport races against the native animation — a race that compensation
// logic can only lose sometimes. So freshly loaded older history is held
// (data already fetched, store already updated) and inserted into the
// rendered list only once the gesture goes quiet. Safety valves: flush when
// the user gets close to the top (a blank top is worse than a small hop) or
// after MAX_HOLD_MS.
const HISTORY_PREPEND_QUIET_MS = 160;
const HISTORY_PREPEND_MAX_HOLD_MS = 1500;
const HISTORY_PREPEND_NEAR_TOP_VIEWPORTS = 1.5;
const HISTORY_PREPEND_MONITOR_INTERVAL_MS = 90;
// A commit is a deferable prepend when older entries were inserted strictly
// above the known content: the previous first key still exists deeper in the
// list and the tail is unchanged. Anything else renders immediately.
const isPrependAboveCommit = (previous: RenderEntry[], next: RenderEntry[]): boolean => {
if (previous.length === 0 || next.length <= previous.length) return false;
if (previous[previous.length - 1]?.key !== next[next.length - 1]?.key) return false;
const previousFirstKey = previous[0]?.key;
const insertedIndex = next.findIndex((entry) => entry.key === previousFirstKey);
return insertedIndex > 0;
};
const tanstackTimelineCache = new Map<string, { keys: readonly string[]; items: VirtualItem[] }>();
const readTanstackTimelineCache = (sessionKey: string, keys: readonly string[]): VirtualItem[] | undefined => {
const entry = tanstackTimelineCache.get(sessionKey);
if (!entry) return undefined;
if (sameKeys(entry.keys, keys)) return entry.cache;
timelineCache.delete(sessionKey);
if (sameKeys(entry.keys, keys)) return entry.items;
tanstackTimelineCache.delete(sessionKey);
return undefined;
};
const writeTimelineCache = (
const writeTanstackTimelineCache = (
sessionKey: string,
keys: readonly string[],
handle: VirtualizerHandle | null | undefined,
virtualizer: TanstackVirtualizerInstance | null | undefined,
): void => {
if (!handle || keys.length === 0) return;
timelineCache.delete(sessionKey);
timelineCache.set(sessionKey, { keys: keys.slice(), cache: handle.cache });
while (timelineCache.size > TIMELINE_CACHE_LIMIT) {
const oldest = timelineCache.keys().next().value;
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;
timelineCache.delete(oldest);
tanstackTimelineCache.delete(oldest);
}
};
@@ -388,6 +432,7 @@ export interface MessageListHandle {
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean;
captureViewportAnchor: () => { messageId: string; offsetTop: number } | null;
restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean;
holdViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => void;
isHistoryVirtualized: () => boolean;
scrollToBottom: () => void;
}
@@ -930,13 +975,11 @@ MessageListEntry.displayName = 'MessageListEntry';
// Inner component that renders staged turn entries.
type StaticHistoryListProps = {
entries: RenderEntry[];
shouldVirtualize: boolean;
engine: HistoryEngine;
contentRef: React.RefObject<HTMLDivElement | null>;
scrollRef?: React.RefObject<HTMLDivElement | null>;
virtualizerRef: React.Ref<VirtualizerHandle>;
registerTanstackVirtualizer?: (virtualizer: TanstackVirtualizerInstance | null) => void;
virtualizerKey: string;
virtualCache?: CacheSnapshot;
shift: boolean;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: () => void;
@@ -950,7 +993,151 @@ type StaticHistoryListProps = {
reviewTransferDirection?: ReviewTransferDirection | null;
};
const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, scrollRef, virtualizerRef, virtualizerKey, virtualCache, shift, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => {
const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef, registerTanstackVirtualizer, virtualizerKey, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => {
const isTanstack = engine === 'tanstack';
// --- Quiet-window prepend (mobile) --------------------------------------
// Gesture tracking for the deferred-prepend decision. Refs only: reading
// them never re-renders, and the render-phase reconcile below needs them.
const touchActiveRef = React.useRef(false);
const lastScrollAtRef = React.useRef(0);
const holdSinceRef = React.useRef<number | null>(null);
const deferPrepends = isTanstack && isMobileSurfaceRuntime();
React.useEffect(() => {
if (!deferPrepends) return;
const element = scrollRef?.current;
if (!element) return;
const onTouchStart = () => { touchActiveRef.current = true; };
const onTouchEnd = () => { touchActiveRef.current = false; };
const onScroll = () => { lastScrollAtRef.current = performance.now(); };
element.addEventListener('touchstart', onTouchStart, { passive: true });
element.addEventListener('touchend', onTouchEnd, { passive: true });
element.addEventListener('touchcancel', onTouchEnd, { passive: true });
element.addEventListener('scroll', onScroll, { passive: true });
return () => {
element.removeEventListener('touchstart', onTouchStart);
element.removeEventListener('touchend', onTouchEnd);
element.removeEventListener('touchcancel', onTouchEnd);
element.removeEventListener('scroll', onScroll);
};
}, [deferPrepends, scrollRef]);
const isGestureActive = React.useCallback(() => (
touchActiveRef.current
|| performance.now() - lastScrollAtRef.current < HISTORY_PREPEND_QUIET_MS
), []);
const isNearTop = React.useCallback(() => {
const element = scrollRef?.current;
if (!element) return true;
return element.scrollTop < element.clientHeight * HISTORY_PREPEND_NEAR_TOP_VIEWPORTS;
}, [scrollRef]);
const [displayEntries, setDisplayEntries] = React.useState(entries);
// Render-phase reconcile (official derived-state pattern): adopt the new
// entries immediately unless this commit is a pure prepend-above landing
// in the middle of an active touch gesture — those wait for quiet.
let renderEntries = displayEntries;
if (entries !== displayEntries) {
const shouldHold = deferPrepends
&& isPrependAboveCommit(displayEntries, entries)
&& isGestureActive()
&& !isNearTop()
&& (holdSinceRef.current === null
|| performance.now() - holdSinceRef.current < HISTORY_PREPEND_MAX_HOLD_MS);
if (shouldHold) {
if (holdSinceRef.current === null) holdSinceRef.current = performance.now();
} else {
holdSinceRef.current = null;
setDisplayEntries(entries);
renderEntries = entries;
}
} else if (holdSinceRef.current !== null) {
holdSinceRef.current = null;
}
// While a prepend is held, poll for the quiet window (touch/momentum have
// no completion event we can await) and flush by re-rendering.
const [, forceFlushTick] = React.useReducer((tick: number) => tick + 1, 0);
React.useEffect(() => {
if (!deferPrepends) return;
const timer = window.setInterval(() => {
if (holdSinceRef.current === null) return;
const expired = performance.now() - holdSinceRef.current >= HISTORY_PREPEND_MAX_HOLD_MS;
if (!isGestureActive() || isNearTop() || expired) {
forceFlushTick();
}
}, HISTORY_PREPEND_MONITOR_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [deferPrepends, isGestureActive, isNearTop]);
const entriesRef = React.useRef(renderEntries);
entriesRef.current = renderEntries;
// Initial-only read: measurement cache restore is a mount-time concern;
// afterwards the live virtualizer owns measurements.
const [initialMeasurements] = React.useState(() => (
isTanstack
? readTanstackTimelineCache(virtualizerKey, entries.map((entry) => entry.key))
: undefined
));
const sizeContainerRef = React.useRef<HTMLDivElement | null>(null);
// Adaptive estimate: rows this session has actually measured are a far
// better predictor for the still-unmeasured ones than a fixed constant.
// Smaller estimate error → smaller anchor corrections when prepended rows
// measure in → less visible drift. The ref keeps estimateSize's identity
// stable so updating the average never triggers a global remeasure.
const estimatedEntrySizeRef = React.useRef(TANSTACK_ESTIMATED_ENTRY_SIZE);
const tanstackVirtualizer = useTanstackVirtualizer<HTMLDivElement, HTMLDivElement>({
count: renderEntries.length,
enabled: isTanstack,
getScrollElement: () => scrollRef?.current ?? null,
estimateSize: () => estimatedEntrySizeRef.current,
overscan: resolveTanstackOverscan(),
scrollToFn: (offset, options, instance) => {
// Expose the new total height before core writes an anchor
// correction so the browser does not clamp the offset to the old
// height (upstream parity).
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,
});
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 (
<MessageListEntry
@@ -974,10 +1161,10 @@ const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, s
);
}, [chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, reviewTransferDirection, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
if (!shouldVirtualize) {
if (engine === 'none') {
return (
<div ref={contentRef} className="relative w-full">
{entries.map((entry) => (
{renderEntries.map((entry) => (
<div
key={entry.key}
data-turn-entry={entry.key}
@@ -989,23 +1176,35 @@ const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, s
);
}
return (
<Virtualizer
key={virtualizerKey}
ref={virtualizerRef}
data={entries}
cache={virtualCache}
bufferSize={resolveMessageListBufferSize()}
shift={shift}
scrollRef={scrollRef}
>
{(entry) => (
<div key={entry.key} data-turn-entry={entry.key}>
{renderEntry(entry)}
if (engine === 'tanstack') {
const virtualItems = tanstackVirtualizer.getVirtualItems();
const startOffset = virtualItems[0]?.start ?? 0;
// Rendered rows stay in normal flow inside a single translated wrapper
// (not per-row absolute positioning) so per-turn sticky user headers
// keep working against the scroll container.
return (
<div ref={sizeContainerRef} className="relative w-full" style={{ height: tanstackVirtualizer.getTotalSize() }}>
<div style={{ transform: `translateY(${startOffset}px)` }}>
{virtualItems.map((item) => {
const entry = renderEntries[item.index];
if (!entry) return null;
return (
<div
key={entry.key}
data-index={item.index}
ref={tanstackVirtualizer.measureElement}
data-turn-entry={entry.key}
>
{renderEntry(entry)}
</div>
);
})}
</div>
)}
</Virtualizer>
);
</div>
);
}
return null;
});
StaticHistoryList.displayName = 'StaticHistoryList';
@@ -1078,9 +1277,8 @@ const StreamingTailContent: React.FC<{
StreamingTailContent.displayName = 'StreamingTailContent';
const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
sessionKey,
disableStaging = false,
messages,
sessionIsWorking = false,
activeStreamingMessageId = null,
@@ -1088,7 +1286,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
retryOverlay = null,
onMessageContentChange,
getAnimationHandlers,
isLoadingOlder,
scrollToBottom,
scrollRef,
directory,
@@ -1176,7 +1373,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
}), [messages]);
const historyContentRef = React.useRef<HTMLDivElement | null>(null);
const historyVirtualizerRef = React.useRef<VirtualizerHandle | null>(null);
const resolveScrollContainer = React.useCallback((): HTMLDivElement | null => {
if (scrollRef?.current) {
return scrollRef.current;
@@ -1278,41 +1474,14 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
}
const historyEntries = staticRenderEntries;
// Virtua hides unmeasured items until ResizeObserver reports their height.
// Mobile momentum scrolling can outrun that measurement and expose blank
// reserved rows, so keep the constrained mobile history mounted normally.
const shouldVirtualizeHistory = !isMobileSurfaceRuntime() && historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
const historyEntryKeys = React.useMemo(() => historyEntries.map((entry) => entry.key), [historyEntries]);
const virtualCache = React.useMemo(
() => (shouldVirtualizeHistory ? readTimelineCache(sessionKey, historyEntryKeys) : undefined),
[historyEntryKeys, sessionKey, shouldVirtualizeHistory],
);
const virtualCacheSessionRef = React.useRef(sessionKey);
const virtualCacheKeysRef = React.useRef(historyEntryKeys);
const setHistoryVirtualizer = React.useCallback((handle: VirtualizerHandle | null) => {
if (!handle) {
writeTimelineCache(
virtualCacheSessionRef.current,
virtualCacheKeysRef.current,
historyVirtualizerRef.current,
);
historyVirtualizerRef.current = null;
return;
}
historyVirtualizerRef.current = handle;
}, []);
React.useEffect(() => {
virtualCacheSessionRef.current = sessionKey;
virtualCacheKeysRef.current = historyEntryKeys;
}, [historyEntryKeys, sessionKey]);
React.useEffect(() => {
const virtualizerForCleanup = historyVirtualizerRef.current;
return () => {
writeTimelineCache(virtualCacheSessionRef.current, virtualCacheKeysRef.current, virtualizerForCleanup);
};
// All surfaces virtualize with @tanstack/react-virtual (see the engine
// note at the top of the file). An unvirtualized list is kept only for
// tiny histories where windowing overhead is not worth it.
const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
const historyEngine: HistoryEngine = shouldVirtualizeHistory ? 'tanstack' : 'none';
const tanstackVirtualizerRef = React.useRef<TanstackVirtualizerInstance | null>(null);
const registerTanstackVirtualizer = React.useCallback((virtualizer: TanstackVirtualizerInstance | null) => {
tanstackVirtualizerRef.current = virtualizer;
}, []);
const allEntries = React.useMemo(() => {
@@ -1410,16 +1579,20 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
}, [resolveScrollContainer]);
const scrollHistoryIndexIntoView = React.useCallback((index: number, behavior: ScrollBehavior = 'auto') => {
if (!shouldVirtualizeHistory || index < 0 || index >= historyEntries.length) {
if (index < 0 || index >= historyEntries.length) {
return false;
}
const virtualizer = historyVirtualizerRef.current;
if (!shouldVirtualizeHistory) {
return false;
}
const virtualizer = tanstackVirtualizerRef.current;
if (!virtualizer) {
return false;
}
virtualizer.scrollToIndex(index, { align: 'start', smooth: behavior === 'smooth' });
virtualizer.scrollToIndex(index, { align: 'start', behavior: behavior === 'smooth' ? 'smooth' : 'auto' });
return true;
}, [historyEntries.length, shouldVirtualizeHistory]);
@@ -1487,6 +1660,47 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
);
},
holdViewportAnchor: (anchor) => {
const container = resolveScrollContainer();
if (!container || typeof window === 'undefined') {
return;
}
let frames = 0;
let stable = 0;
let cancelled = false;
const cancelOnUserInput = () => {
cancelled = true;
container.removeEventListener('touchstart', cancelOnUserInput);
container.removeEventListener('wheel', cancelOnUserInput);
};
container.addEventListener('touchstart', cancelOnUserInput, { passive: true });
container.addEventListener('wheel', cancelOnUserInput, { passive: true });
const step = () => {
if (cancelled) return;
const element = findMessageElement(anchor.messageId);
if (element) {
const delta = element.getBoundingClientRect().top
- container.getBoundingClientRect().top
- anchor.offsetTop;
if (Math.abs(delta) > 0.5) {
container.scrollTop += delta;
stable = 0;
} else {
stable += 1;
}
}
frames += 1;
if (stable >= ANCHOR_HOLD_STABLE_FRAMES || frames >= ANCHOR_HOLD_MAX_FRAMES) {
container.removeEventListener('touchstart', cancelOnUserInput);
container.removeEventListener('wheel', cancelOnUserInput);
return;
}
window.requestAnimationFrame(step);
};
window.requestAnimationFrame(step);
},
isHistoryVirtualized: () => shouldVirtualizeHistory,
captureViewportAnchor: () => {
@@ -1559,8 +1773,8 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
},
scrollToBottom: () => {
if (shouldVirtualizeHistory && historyEntries.length > 0) {
historyVirtualizerRef.current?.scrollToIndex(historyEntries.length - 1, { align: 'end' });
if (shouldVirtualizeHistory && historyEntries.length > 0 && tanstackVirtualizerRef.current) {
tanstackVirtualizerRef.current.scrollToEnd();
return;
}
const container = resolveScrollContainer();
@@ -1589,27 +1803,32 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
<div>
<FadeInDisabledProvider disabled={disableFadeIn}>
<div className="relative w-full">
<StaticHistoryList
entries={historyEntries}
shouldVirtualize={shouldVirtualizeHistory}
contentRef={historyContentRef}
scrollRef={scrollRef}
virtualizerRef={setHistoryVirtualizer}
virtualizerKey={sessionKey}
virtualCache={virtualCache}
shift={isLoadingOlder || disableStaging}
onMessageContentChange={stableHistoryContentChange}
getAnimationHandlers={stableGetAnimationHandlers}
scrollToBottom={stableScrollToBottom}
stickyUserHeader={stickyUserHeader}
defaultActivityExpanded={defaultActivityExpanded}
turnUiStates={turnUiStates}
onToggleTurnGroup={toggleTurnGroup}
chatRenderMode={chatRenderMode}
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
reviewTransferDirection={reviewTransferDirection}
/>
{/* Virtualized history rows unmount/remount during scroll;
re-running the reveal fade on every remount reads as
blinking. History content is never "new", so fade-in
is disabled there — the streaming tail keeps it. */}
<FadeInDisabledProvider disabled={shouldVirtualizeHistory}>
<StaticHistoryList
key={sessionKey}
entries={historyEntries}
engine={historyEngine}
contentRef={historyContentRef}
scrollRef={scrollRef}
registerTanstackVirtualizer={registerTanstackVirtualizer}
virtualizerKey={sessionKey}
onMessageContentChange={stableHistoryContentChange}
getAnimationHandlers={stableGetAnimationHandlers}
scrollToBottom={stableScrollToBottom}
stickyUserHeader={stickyUserHeader}
defaultActivityExpanded={defaultActivityExpanded}
turnUiStates={turnUiStates}
onToggleTurnGroup={toggleTurnGroup}
chatRenderMode={chatRenderMode}
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
reviewTransferDirection={reviewTransferDirection}
/>
</FadeInDisabledProvider>
{trailingStreamingEntry ? (
<StreamingTailContent
entry={trailingStreamingEntry}
@@ -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;
+1
View File
@@ -1279,6 +1279,7 @@ export const dict = {
'diffView.reviewDialog.actions.starting': 'Starting...',
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow',
'chat.history.loadOlder': 'Load older messages',
'chat.autoReview.title': 'Code review loop is running',
'chat.autoReview.status.waitingForReviewer': 'Waiting for reviewer',
'chat.autoReview.status.waitingForImplementer': 'Waiting for implementer',
+1
View File
@@ -1245,6 +1245,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.actions.starting': 'Iniciando...',
'diffView.reviewDialog.toast.noSessionDirectory': 'El directorio de la sesión no está disponible',
'diffView.reviewDialog.toast.startFailed': 'No se pudo iniciar el flujo de revisión',
'chat.history.loadOlder': 'Cargar mensajes anteriores',
'chat.autoReview.title': 'El ciclo de revisión de código está en curso',
'chat.autoReview.status.waitingForReviewer': 'Esperando al revisor',
'chat.autoReview.status.waitingForImplementer': 'Esperando al implementador',
+1
View File
@@ -1113,6 +1113,7 @@ export const dict = {
'diffView.reviewDialog.actions.starting': 'Démarrage...',
'diffView.reviewDialog.toast.noSessionDirectory': 'Le dossier de session est indisponible',
'diffView.reviewDialog.toast.startFailed': 'Impossible de démarrer le flux de revue',
'chat.history.loadOlder': 'Charger les messages précédents',
'chat.autoReview.title': 'La boucle de revue de code est en cours',
'chat.autoReview.status.waitingForReviewer': 'En attente du reviewer',
'chat.autoReview.status.waitingForImplementer': 'En attente de limplémenteur',
+1
View File
@@ -1284,6 +1284,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.hunk.discardTitle': 'ハンク{index}を破棄',
'diffView.hunk.unavailable': 'このハンクはもう利用できません。差分を更新してからもう一度お試しください。',
'diffView.hunk.unsupported': '個別のハンクのステージングはこのランタイムではサポートされていません。',
'chat.history.loadOlder': '以前のメッセージを読み込む',
'chat.autoReview.title': 'コードレビューループが実行中です',
'chat.autoReview.status.waitingForReviewer': 'レビュアーを待機中',
'chat.autoReview.status.waitingForImplementer': '実装者を待機中',
+1
View File
@@ -1282,6 +1282,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.actions.starting': '시작 중...',
'diffView.reviewDialog.toast.noSessionDirectory': '세션 디렉터리를 사용할 수 없습니다',
'diffView.reviewDialog.toast.startFailed': '리뷰 흐름을 시작하지 못했습니다',
'chat.history.loadOlder': '이전 메시지 불러오기',
'chat.autoReview.title': '코드 리뷰 루프 실행 중',
'chat.autoReview.status.waitingForReviewer': '리뷰어를 기다리는 중',
'chat.autoReview.status.waitingForImplementer': '구현 에이전트를 기다리는 중',
+1
View File
@@ -1481,6 +1481,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.actions.starting': 'Uruchamianie...',
'diffView.reviewDialog.toast.noSessionDirectory': 'Katalog sesji jest niedostępny',
'diffView.reviewDialog.toast.startFailed': 'Nie udało się uruchomić flow review',
'chat.history.loadOlder': 'Wczytaj starsze wiadomości',
'chat.autoReview.title': 'Pętla code review trwa',
'chat.autoReview.status.waitingForReviewer': 'Oczekiwanie na reviewera',
'chat.autoReview.status.waitingForImplementer': 'Oczekiwanie na implementatora',
@@ -1245,6 +1245,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.actions.starting': 'Iniciando...',
'diffView.reviewDialog.toast.noSessionDirectory': 'O diretório da sessão está indisponível',
'diffView.reviewDialog.toast.startFailed': 'Falha ao iniciar o fluxo de revisão',
'chat.history.loadOlder': 'Carregar mensagens anteriores',
'chat.autoReview.title': 'O ciclo de revisão de código está em andamento',
'chat.autoReview.status.waitingForReviewer': 'Aguardando o revisor',
'chat.autoReview.status.waitingForImplementer': 'Aguardando o implementador',
+1
View File
@@ -1245,6 +1245,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.actions.starting': 'Запуск...',
'diffView.reviewDialog.toast.noSessionDirectory': 'Директорія сесії недоступна',
'diffView.reviewDialog.toast.startFailed': 'Не вдалося запустити review flow',
'chat.history.loadOlder': 'Завантажити ще',
'chat.autoReview.title': 'Цикл код-ревʼю триває',
'chat.autoReview.status.waitingForReviewer': 'Очікуємо ревʼювера',
'chat.autoReview.status.waitingForImplementer': 'Очікуємо імплементатора',
@@ -1245,6 +1245,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.actions.starting': 'Starting...',
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow',
'chat.history.loadOlder': '加载更早的消息',
'chat.autoReview.title': '代码审查循环正在运行',
'chat.autoReview.status.waitingForReviewer': '等待审查者',
'chat.autoReview.status.waitingForImplementer': '等待实现者',
@@ -1255,6 +1255,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.actions.starting': 'Starting...',
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow',
'chat.history.loadOlder': '載入更早的訊息',
'chat.autoReview.title': '程式碼審查循環執行中',
'chat.autoReview.status.waitingForReviewer': '等待審查者',
'chat.autoReview.status.waitingForImplementer': '等待實作者',
+9 -5
View File
@@ -503,11 +503,15 @@ export function useSync() {
shouldLoadMessages ? loadMessages(sessionID, { isStale }) : Promise.resolve(),
])
// Progressive mount on desktop: after the initial page resolves, if the
// session isn't stale and the server indicated more messages, dispatch a
// second fetch to prepend older history. Mobile avoids this background
// prepend because adding rows after first paint on a narrow viewport can
// visibly shift the timeline; user scroll still loads older history.
// Progressive mount (desktop/VS Code): after the initial page
// resolves, if the session isn't stale and the server indicated more
// messages, dispatch a second fetch to prepend older history — it
// gives the scroll container headroom so the scroll-up trigger fires
// seamlessly. Mobile deliberately opts out: it has no scroll-position
// trigger at all — ALL older history loads happen through the
// explicit "load older" button at the top, so every prepend lands
// from a resting state the user initiated. (The initial page itself,
// including the turn-boundary extension, is unaffected.)
if (!isStale() && !isMobileSurfaceRuntime()) {
const currentMeta = getMetaFor(sessionID)
if (currentMeta.cursor && !currentMeta.complete) {
@@ -0,0 +1,36 @@
diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs
index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..bcee09df7377c37ffb220606b741c9ff434b3470 100644
--- a/dist/cjs/index.cjs
+++ b/dist/cjs/index.cjs
@@ -723,10 +723,12 @@ class Virtualizer {
this.range = null;
return null;
}
+ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0);
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
this.range = calculateRangeImpl(
measurements,
outerSize,
- scrollOffset,
+ effectiveScrollOffset,
lanes,
// Pass the typed array so binary search + forward-walk can read
// start/end directly from Float64Array, skipping the Proxy traps.
diff --git a/dist/esm/index.js b/dist/esm/index.js
index 3032c0ca457582be3f47923cba1f7d92c848745c..90b574881a073aabac99c075f7eab0a8f363fff6 100644
--- a/dist/esm/index.js
+++ b/dist/esm/index.js
@@ -721,10 +721,12 @@ class Virtualizer {
this.range = null;
return null;
}
+ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0);
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
this.range = calculateRangeImpl(
measurements,
outerSize,
- scrollOffset,
+ effectiveScrollOffset,
lanes,
// Pass the typed array so binary search + forward-walk can read
// start/end directly from Float64Array, skipping the Proxy traps.