From 2bce38cfbb57df2bfe86593c40e672130b60704b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 3 Jul 2026 13:49:53 +0300 Subject: [PATCH] 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 --- bun.lock | 16 +- package.json | 3 + packages/ui/package.json | 1 + .../ui/src/components/chat/ChatContainer.tsx | 35 +- .../ui/src/components/chat/MessageList.tsx | 443 +++++++++++++----- .../chat/hooks/useChatTimelineController.ts | 45 +- packages/ui/src/lib/i18n/messages/en.ts | 1 + packages/ui/src/lib/i18n/messages/es.ts | 1 + packages/ui/src/lib/i18n/messages/fr.ts | 1 + packages/ui/src/lib/i18n/messages/ja.ts | 1 + packages/ui/src/lib/i18n/messages/ko.ts | 1 + packages/ui/src/lib/i18n/messages/pl.ts | 1 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 + packages/ui/src/lib/i18n/messages/uk.ts | 1 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 1 + packages/ui/src/sync/use-sync.ts | 14 +- patches/@tanstack%2Fvirtual-core@3.17.3.patch | 36 ++ 18 files changed, 458 insertions(+), 145 deletions(-) create mode 100644 patches/@tanstack%2Fvirtual-core@3.17.3.patch diff --git a/bun.lock b/bun.lock index e49df436..37335213 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index c18f83a4..8f83f714 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index f8ae5ec1..862ffbb1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index b73c52ee..0a25eb42 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -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) => { if (event.defaultPrevented || shouldIgnoreChatNavigationTarget(event.target)) { return; @@ -218,6 +224,21 @@ const ChatViewport = React.memo(({ data-scrollbar="chat" >
+ {showLoadOlderButton && ( +
+ +
+ )} = ({ 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 = ({ autoOpenDraft = tr sessionQuestions={sessionQuestions} sessionPermissions={sessionPermissions} isProgrammaticFollowActive={isFollowingProgrammatically} + showLoadOlderButton={showLoadOlderButton} + onLoadOlder={handleLoadOlderClick} />
(); -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(); +// --- 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; +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(); + +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; scrollRef?: React.RefObject; - virtualizerRef: React.Ref; + 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(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(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({ + 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 ( - {entries.map((entry) => ( + {renderEntries.map((entry) => (
- {(entry) => ( -
- {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 ( +
+
+ {virtualItems.map((item) => { + const entry = renderEntries[item.index]; + if (!entry) return null; + return ( +
+ {renderEntry(entry)} +
+ ); + })}
- )} - - ); +
+ ); + } + + return null; }); StaticHistoryList.displayName = 'StaticHistoryList'; @@ -1078,9 +1277,8 @@ const StreamingTailContent: React.FC<{ StreamingTailContent.displayName = 'StreamingTailContent'; -const MessageList = React.forwardRef(({ +const MessageList = React.forwardRef(({ sessionKey, - disableStaging = false, messages, sessionIsWorking = false, activeStreamingMessageId = null, @@ -1088,7 +1286,6 @@ const MessageList = React.forwardRef(({ retryOverlay = null, onMessageContentChange, getAnimationHandlers, - isLoadingOlder, scrollToBottom, scrollRef, directory, @@ -1176,7 +1373,6 @@ const MessageList = React.forwardRef(({ }), [messages]); const historyContentRef = React.useRef(null); - const historyVirtualizerRef = React.useRef(null); const resolveScrollContainer = React.useCallback((): HTMLDivElement | null => { if (scrollRef?.current) { return scrollRef.current; @@ -1278,41 +1474,14 @@ const MessageList = React.forwardRef(({ } 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(null); + const registerTanstackVirtualizer = React.useCallback((virtualizer: TanstackVirtualizerInstance | null) => { + tanstackVirtualizerRef.current = virtualizer; }, []); const allEntries = React.useMemo(() => { @@ -1410,16 +1579,20 @@ const MessageList = React.forwardRef(({ }, [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(({ ); }, + 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(({ }, 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(({
- + {/* 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. */} + + + {trailingStreamingEntry ? ( { - 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; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 18375b2b..65f299cc 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index d1a93005..31f6311a 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1245,6 +1245,7 @@ export const dict: Record = { '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', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index c4eb7f12..ac7833a2 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -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 l’implémenteur', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 306ae0fe..e1095738 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1284,6 +1284,7 @@ export const dict: Record = { 'diffView.hunk.discardTitle': 'ハンク{index}を破棄', 'diffView.hunk.unavailable': 'このハンクはもう利用できません。差分を更新してからもう一度お試しください。', 'diffView.hunk.unsupported': '個別のハンクのステージングはこのランタイムではサポートされていません。', + 'chat.history.loadOlder': '以前のメッセージを読み込む', 'chat.autoReview.title': 'コードレビューループが実行中です', 'chat.autoReview.status.waitingForReviewer': 'レビュアーを待機中', 'chat.autoReview.status.waitingForImplementer': '実装者を待機中', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index d329885d..440c2b84 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1282,6 +1282,7 @@ export const dict: Record = { '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': '구현 에이전트를 기다리는 중', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 30cd07a1..e4c6cb2d 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1481,6 +1481,7 @@ export const dict: Record = { '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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index bcbf1aed..e48993b2 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1245,6 +1245,7 @@ export const dict: Record = { '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', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 38414a0a..9e30739a 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1245,6 +1245,7 @@ export const dict: Record = { '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': 'Очікуємо імплементатора', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 09ad5833..9956d237 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1245,6 +1245,7 @@ export const dict: Record = { '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': '等待实现者', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 34cdf134..68633d5c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1255,6 +1255,7 @@ export const dict: Record = { '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': '等待實作者', diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index 6271ffde..4f741d68 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -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) { diff --git a/patches/@tanstack%2Fvirtual-core@3.17.3.patch b/patches/@tanstack%2Fvirtual-core@3.17.3.patch new file mode 100644 index 00000000..86e09c7b --- /dev/null +++ b/patches/@tanstack%2Fvirtual-core@3.17.3.patch @@ -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.