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:
@@ -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=="],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '実装者を待機中',
|
||||
|
||||
@@ -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': '구현 에이전트를 기다리는 중',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '等待實作者',
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user