fix: stabilize chat history prepend scroll preservation on mobile and desktop
- Mobile: defeat iOS momentum scroll when compensating history prepend (overflow toggle + short rAF watchdog); disable history virtualization and post-paint background prepends; preload Markdown renderer and use plain-text Suspense fallback to avoid first-frame geometry shifts - Desktop: stop double-compensating prepends on the virtualized list - virtua shift owns the adjustment; remove sticky-anchor heuristics that misfired as failed restores - Sync: skip no-op store writes when messages/parts are unchanged
This commit is contained in:
@@ -16,6 +16,7 @@ import { initializeLocale, I18nProvider } from '@/lib/i18n';
|
|||||||
import { initializeAppearancePreferences, syncDesktopSettings } from '@/lib/persistence';
|
import { initializeAppearancePreferences, syncDesktopSettings } from '@/lib/persistence';
|
||||||
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
|
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
|
||||||
import { startTypographyWatcher } from '@/lib/typographyWatcher';
|
import { startTypographyWatcher } from '@/lib/typographyWatcher';
|
||||||
|
import { preloadMarkdownRenderer } from '@/components/chat/markdownRendererLoader';
|
||||||
import { MobileApp } from './MobileApp';
|
import { MobileApp } from './MobileApp';
|
||||||
|
|
||||||
const initializeSharedPreferences = () => {
|
const initializeSharedPreferences = () => {
|
||||||
@@ -42,6 +43,7 @@ const initializeSharedPreferences = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function renderMobileApp(apis: RuntimeAPIs) {
|
export function renderMobileApp(apis: RuntimeAPIs) {
|
||||||
|
preloadMarkdownRenderer();
|
||||||
initializeSharedPreferences();
|
initializeSharedPreferences();
|
||||||
|
|
||||||
// Expose the widget snapshot builder so the native shell can read the session overview
|
// Expose the widget snapshot builder so the native shell can read the session overview
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
|
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { loadMarkdownRendererModule } from './markdownRendererLoader';
|
||||||
|
|
||||||
// Thin lazy wrapper around the MarkdownRenderer implementation.
|
// Thin lazy wrapper around the MarkdownRenderer implementation.
|
||||||
// The full implementation (marked + Shiki highlighting + KaTeX + morphdom
|
// The full implementation (marked + Shiki highlighting + KaTeX + morphdom
|
||||||
@@ -7,23 +10,41 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
|||||||
// initial bundle lean.
|
// initial bundle lean.
|
||||||
|
|
||||||
const MarkdownRendererLazy = lazyWithChunkRecovery(() =>
|
const MarkdownRendererLazy = lazyWithChunkRecovery(() =>
|
||||||
import('./MarkdownRendererImpl').then((m) => ({ default: m.MarkdownRenderer }))
|
loadMarkdownRendererModule().then((m) => ({ default: m.MarkdownRenderer }))
|
||||||
);
|
);
|
||||||
|
|
||||||
const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() =>
|
const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() =>
|
||||||
import('./MarkdownRendererImpl').then((m) => ({ default: m.SimpleMarkdownRenderer }))
|
loadMarkdownRendererModule().then((m) => ({ default: m.SimpleMarkdownRenderer }))
|
||||||
);
|
);
|
||||||
|
|
||||||
const fallback = <div className="break-words w-full min-w-0" />;
|
const fallback = <div className="break-words w-full min-w-0" />;
|
||||||
|
|
||||||
|
const fallbackContentClassName = (variant: unknown): string => {
|
||||||
|
if (variant === 'tool') return 'markdown-content markdown-tool';
|
||||||
|
if (variant === 'reasoning') return 'markdown-content markdown-reasoning';
|
||||||
|
return 'markdown-content leading-relaxed';
|
||||||
|
};
|
||||||
|
|
||||||
|
const MobileMarkdownFallback = (props: { content?: unknown; className?: unknown; variant?: unknown }) => {
|
||||||
|
if (!isMobileSurfaceRuntime() || typeof props.content !== 'string' || props.content.length === 0) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('break-words w-full min-w-0 whitespace-pre-wrap', fallbackContentClassName(props.variant), typeof props.className === 'string' ? props.className : undefined)}>
|
||||||
|
{props.content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const MarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownRendererLazy>> = (props) => (
|
export const MarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownRendererLazy>> = (props) => (
|
||||||
<React.Suspense fallback={fallback}>
|
<React.Suspense fallback={<MobileMarkdownFallback {...props} />}>
|
||||||
<MarkdownRendererLazy {...props} />
|
<MarkdownRendererLazy {...props} />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SimpleMarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy>> = (props) => (
|
export const SimpleMarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy>> = (props) => (
|
||||||
<React.Suspense fallback={fallback}>
|
<React.Suspense fallback={<MobileMarkdownFallback {...props} />}>
|
||||||
<SimpleMarkdownRendererLazy {...props} />
|
<SimpleMarkdownRendererLazy {...props} />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -155,6 +155,21 @@ const getMessageParentId = (message: ChatMessageEntry): string | null => {
|
|||||||
return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null;
|
return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isInsideStuckSticky = (node: HTMLElement, container: HTMLElement, containerTop: number): boolean => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
|
||||||
|
let current: HTMLElement | null = node;
|
||||||
|
while (current && current !== container) {
|
||||||
|
const computed = window.getComputedStyle(current);
|
||||||
|
if (computed.position === 'sticky' && current.getBoundingClientRect().top <= containerTop + 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => {
|
const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => {
|
||||||
if (!message) return false;
|
if (!message) return false;
|
||||||
if (resolveMessageRole(message) !== 'user') return false;
|
if (resolveMessageRole(message) !== 'user') return false;
|
||||||
@@ -373,6 +388,7 @@ export interface MessageListHandle {
|
|||||||
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean;
|
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean;
|
||||||
captureViewportAnchor: () => { messageId: string; offsetTop: number } | null;
|
captureViewportAnchor: () => { messageId: string; offsetTop: number } | null;
|
||||||
restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean;
|
restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean;
|
||||||
|
isHistoryVirtualized: () => boolean;
|
||||||
scrollToBottom: () => void;
|
scrollToBottom: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1262,7 +1278,10 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const historyEntries = staticRenderEntries;
|
const historyEntries = staticRenderEntries;
|
||||||
const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
|
// 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 historyEntryKeys = React.useMemo(() => historyEntries.map((entry) => entry.key), [historyEntries]);
|
||||||
const virtualCache = React.useMemo(
|
const virtualCache = React.useMemo(
|
||||||
() => (shouldVirtualizeHistory ? readTimelineCache(sessionKey, historyEntryKeys) : undefined),
|
() => (shouldVirtualizeHistory ? readTimelineCache(sessionKey, historyEntryKeys) : undefined),
|
||||||
@@ -1468,6 +1487,8 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
isHistoryVirtualized: () => shouldVirtualizeHistory,
|
||||||
|
|
||||||
captureViewportAnchor: () => {
|
captureViewportAnchor: () => {
|
||||||
const container = resolveScrollContainer();
|
const container = resolveScrollContainer();
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@@ -1486,9 +1507,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const computed = window.getComputedStyle(node);
|
return !isInsideStuckSticky(node, container, containerRect.top);
|
||||||
const isStuckSticky = computed.position === 'sticky' && rect.top <= containerRect.top + 1;
|
|
||||||
return !isStuckSticky;
|
|
||||||
}) ?? nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1);
|
}) ?? nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1);
|
||||||
if (!firstVisible) {
|
if (!firstVisible) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
import { shouldAutoLoadEarlierForUnderfilledPinnedViewport } from './useChatTimelineController';
|
import {
|
||||||
|
isOlderHistoryPrependCommit,
|
||||||
|
shouldAutoLoadEarlierForUnderfilledPinnedViewport,
|
||||||
|
} from './useChatTimelineController';
|
||||||
|
|
||||||
const baseInput = {
|
const baseInput = {
|
||||||
sessionId: 'ses_1',
|
sessionId: 'ses_1',
|
||||||
@@ -39,3 +42,29 @@ describe('shouldAutoLoadEarlierForUnderfilledPinnedViewport', () => {
|
|||||||
})).toBe(false);
|
})).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isOlderHistoryPrependCommit', () => {
|
||||||
|
test('detects older messages inserted above the existing timeline', () => {
|
||||||
|
expect(isOlderHistoryPrependCommit({
|
||||||
|
previousOldestId: 'msg_2',
|
||||||
|
previousNewestId: 'msg_4',
|
||||||
|
currentOldestId: 'msg_1',
|
||||||
|
currentNewestId: 'msg_4',
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not treat appends or replacements as prepends', () => {
|
||||||
|
expect(isOlderHistoryPrependCommit({
|
||||||
|
previousOldestId: 'msg_2',
|
||||||
|
previousNewestId: 'msg_4',
|
||||||
|
currentOldestId: 'msg_2',
|
||||||
|
currentNewestId: 'msg_5',
|
||||||
|
})).toBe(false);
|
||||||
|
expect(isOlderHistoryPrependCommit({
|
||||||
|
previousOldestId: 'msg_2',
|
||||||
|
previousNewestId: 'msg_4',
|
||||||
|
currentOldestId: 'msg_1',
|
||||||
|
currentNewestId: 'msg_5',
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -126,6 +126,75 @@ export const shouldAutoLoadEarlierForUnderfilledPinnedViewport = (input: {
|
|||||||
return input.scrollHeight <= input.clientHeight + 1;
|
return input.scrollHeight <= input.clientHeight + 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isOlderHistoryPrependCommit = (input: {
|
||||||
|
previousOldestId: string | null;
|
||||||
|
previousNewestId: string | null;
|
||||||
|
currentOldestId: string | null;
|
||||||
|
currentNewestId: string | null;
|
||||||
|
}): boolean => Boolean(
|
||||||
|
input.previousOldestId
|
||||||
|
&& input.currentOldestId
|
||||||
|
&& input.currentOldestId !== input.previousOldestId
|
||||||
|
&& input.previousNewestId
|
||||||
|
&& input.currentNewestId
|
||||||
|
&& input.currentNewestId === input.previousNewestId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// iOS WKWebView ignores programmatic scrollTop writes while a touch drag or
|
||||||
|
// momentum (fling) scroll is active: the native scroll animation keeps running
|
||||||
|
// and overwrites the value on the next frame. The mobile history threshold is
|
||||||
|
// large enough that the prepend commit almost always lands mid-fling, so a
|
||||||
|
// plain `container.scrollTop = target` never sticks. Toggling overflow kills
|
||||||
|
// the native scroll synchronously (pre-paint, invisible inside a layout
|
||||||
|
// effect); a short post-paint watchdog re-asserts the target if residual
|
||||||
|
// momentum still drags the viewport upward.
|
||||||
|
const MOMENTUM_WATCHDOG_FRAMES = 20;
|
||||||
|
const MOMENTUM_WATCHDOG_TOLERANCE_PX = 4;
|
||||||
|
|
||||||
|
const setScrollTopDefeatingMomentum = (container: HTMLElement, target: number) => {
|
||||||
|
const previousOverflow = container.style.overflow;
|
||||||
|
container.style.overflow = 'hidden';
|
||||||
|
container.scrollTop = target;
|
||||||
|
void container.scrollHeight;
|
||||||
|
container.style.overflow = previousOverflow;
|
||||||
|
container.scrollTop = target;
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
let cancelled = false;
|
||||||
|
let frames = 0;
|
||||||
|
const cancelOnUserTouch = () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
container.addEventListener('touchstart', cancelOnUserTouch, { passive: true, once: true });
|
||||||
|
const watch = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
// Only correct upward drift (residual momentum). Downward movement or
|
||||||
|
// content growth above the viewport must not be fought here.
|
||||||
|
if (container.scrollTop < target - MOMENTUM_WATCHDOG_TOLERANCE_PX) {
|
||||||
|
container.scrollTop = target;
|
||||||
|
}
|
||||||
|
frames += 1;
|
||||||
|
if (frames < MOMENTUM_WATCHDOG_FRAMES) {
|
||||||
|
window.requestAnimationFrame(watch);
|
||||||
|
} else {
|
||||||
|
container.removeEventListener('touchstart', cancelOnUserTouch);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.requestAnimationFrame(watch);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasInsertedBeforeKnownOldest = (
|
||||||
|
previousOldestId: string | null,
|
||||||
|
currentOldestId: string | null,
|
||||||
|
messages: ChatMessageEntry[],
|
||||||
|
): boolean => {
|
||||||
|
if (!previousOldestId || !currentOldestId || currentOldestId === previousOldestId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages.some((message) => message.info.id === previousOldestId);
|
||||||
|
};
|
||||||
|
|
||||||
export const useChatTimelineController = ({
|
export const useChatTimelineController = ({
|
||||||
sessionId,
|
sessionId,
|
||||||
messages,
|
messages,
|
||||||
@@ -339,9 +408,12 @@ export const useChatTimelineController = ({
|
|||||||
// before triggering the state change. useLayoutEffect consumes it
|
// before triggering the state change. useLayoutEffect consumes it
|
||||||
// after React commits new DOM — before the browser paints.
|
// after React commits new DOM — before the browser paints.
|
||||||
const prePrependScrollRef = React.useRef<{
|
const prePrependScrollRef = React.useRef<{
|
||||||
|
sessionId: string | null;
|
||||||
height: number;
|
height: number;
|
||||||
top: number;
|
top: number;
|
||||||
anchor: ViewportAnchor | null;
|
anchor: ViewportAnchor | null;
|
||||||
|
oldestId: string | null;
|
||||||
|
newestId: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => {
|
const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => {
|
||||||
@@ -364,26 +436,47 @@ export const useChatTimelineController = ({
|
|||||||
scrollHeight: number;
|
scrollHeight: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
prePrependScrollRef.current = null;
|
||||||
|
prependTrackingRef.current = null;
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const snap = prePrependScrollRef.current;
|
let snap = prePrependScrollRef.current;
|
||||||
const prev = prependTrackingRef.current;
|
const prev = prependTrackingRef.current;
|
||||||
const currentOldestId = renderedMessages[0]?.info?.id ?? null;
|
const currentOldestId = renderedMessages[0]?.info?.id ?? null;
|
||||||
const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null;
|
const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null;
|
||||||
// A prepend = content inserted ABOVE the viewport: the oldest message id
|
// A prepend = content inserted ABOVE the viewport: either the newest
|
||||||
// changed while the newest stayed the same. This distinguishes a history
|
// stayed fixed, or the old first message still exists below a new first
|
||||||
// load from a bottom append, a streaming part growing, or a session switch.
|
// message. The latter keeps preservation alive if a tail append lands in
|
||||||
const isPrepend = Boolean(
|
// the same commit as the history page.
|
||||||
prev
|
const isPrepend = prev
|
||||||
&& prev.oldestId
|
? isOlderHistoryPrependCommit({
|
||||||
&& currentOldestId
|
previousOldestId: prev.oldestId,
|
||||||
&& currentOldestId !== prev.oldestId
|
previousNewestId: prev.newestId,
|
||||||
&& prev.newestId
|
currentOldestId,
|
||||||
&& currentNewestId
|
currentNewestId,
|
||||||
&& currentNewestId === prev.newestId,
|
}) || hasInsertedBeforeKnownOldest(prev.oldestId, currentOldestId, renderedMessages)
|
||||||
);
|
: false;
|
||||||
|
|
||||||
|
if (snap && snap.sessionId !== sessionIdRef.current) {
|
||||||
|
prePrependScrollRef.current = null;
|
||||||
|
snap = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSnapshotPrepend = snap
|
||||||
|
? isOlderHistoryPrependCommit({
|
||||||
|
previousOldestId: snap.oldestId,
|
||||||
|
previousNewestId: snap.newestId,
|
||||||
|
currentOldestId,
|
||||||
|
currentNewestId,
|
||||||
|
}) || hasInsertedBeforeKnownOldest(snap.oldestId, currentOldestId, renderedMessages)
|
||||||
|
: false;
|
||||||
|
const didPrepend = isPrepend || isSnapshotPrepend;
|
||||||
|
const shouldConsumeSnapshot = Boolean(snap && (isPrepend || isSnapshotPrepend));
|
||||||
|
|
||||||
const updateTracking = () => {
|
const updateTracking = () => {
|
||||||
prependTrackingRef.current = {
|
prependTrackingRef.current = {
|
||||||
@@ -393,6 +486,22 @@ export const useChatTimelineController = ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshPendingSnapshot = () => {
|
||||||
|
const pending = prePrependScrollRef.current;
|
||||||
|
if (!pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
prePrependScrollRef.current = {
|
||||||
|
...pending,
|
||||||
|
height: container.scrollHeight,
|
||||||
|
top: container.scrollTop,
|
||||||
|
anchor: captureViewportAnchor(),
|
||||||
|
oldestId: currentOldestId,
|
||||||
|
newestId: currentNewestId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
if (isPinnedRef.current) {
|
if (isPinnedRef.current) {
|
||||||
// Bottom-pinned. Only content inserted ABOVE (a prepend / history load)
|
// Bottom-pinned. Only content inserted ABOVE (a prepend / history load)
|
||||||
// needs an explicit re-pin: with overflow-anchor:none the browser leaves
|
// needs an explicit re-pin: with overflow-anchor:none the browser leaves
|
||||||
@@ -407,38 +516,74 @@ export const useChatTimelineController = ({
|
|||||||
// best, and the source of the old up/down jiggle on send / from the
|
// best, and the source of the old up/down jiggle on send / from the
|
||||||
// queue / while streaming. So for an append we do nothing and let
|
// queue / while streaming. So for an append we do nothing and let
|
||||||
// auto-follow own it.
|
// auto-follow own it.
|
||||||
if (snap || isPrepend) {
|
if (didPrepend) {
|
||||||
prePrependScrollRef.current = null;
|
prePrependScrollRef.current = null;
|
||||||
goToBottom('instant');
|
goToBottom('instant');
|
||||||
|
} else if (snap) {
|
||||||
|
refreshPendingSnapshot();
|
||||||
}
|
}
|
||||||
updateTracking();
|
updateTracking();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snap) {
|
// When the history list is virtualized, virtua runs with `shift` during
|
||||||
|
// history loads and compensates the prepend internally. Manual
|
||||||
|
// height-delta compensation on top of that applies the same delta twice
|
||||||
|
// and throws the viewport far downward. Anchor restore stays allowed —
|
||||||
|
// it corrects to an absolute element position, so it cannot double up.
|
||||||
|
const historyVirtualized = messageListRef.current?.isHistoryVirtualized() ?? false;
|
||||||
|
|
||||||
|
if (snap && shouldConsumeSnapshot) {
|
||||||
prePrependScrollRef.current = null;
|
prePrependScrollRef.current = null;
|
||||||
|
const heightDelta = container.scrollHeight - snap.height;
|
||||||
|
const applyHeightDelta = (): boolean => {
|
||||||
|
if (historyVirtualized || heightDelta <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
container.scrollTop = snap.top + heightDelta;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isMobileSurfaceRuntime() && heightDelta > 0) {
|
||||||
|
setScrollTopDefeatingMomentum(container, snap.top + heightDelta);
|
||||||
|
updateTracking();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// When a viewport anchor is available, delegate to MessageList
|
// When a viewport anchor is available, delegate to MessageList
|
||||||
// restoreViewportAnchor which falls back to virtualizer-aware
|
// restoreViewportAnchor which falls back to virtualizer-aware
|
||||||
// scrollHistoryIndexIntoView when the element is not in the DOM.
|
// 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.
|
||||||
if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) {
|
if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) {
|
||||||
// Fallback: height-delta compensation
|
// Fallback: height-delta compensation
|
||||||
const delta = container.scrollHeight - snap.height;
|
applyHeightDelta();
|
||||||
if (delta > 0) {
|
|
||||||
container.scrollTop = snap.top + delta;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (isPrepend && prev) {
|
} else if (isPrepend && prev && !historyVirtualized) {
|
||||||
// Released viewport: preserve the read position by compensating for the
|
// Released viewport: preserve the read position by compensating for the
|
||||||
// exact height the prepend added above, with no intermediate frame for
|
// exact height the prepend added above, with no intermediate frame for
|
||||||
// auto-follow to fight.
|
// auto-follow to fight. Virtualized lists skip this — virtua `shift`
|
||||||
|
// already compensated the prepend.
|
||||||
const delta = container.scrollHeight - prev.scrollHeight;
|
const delta = container.scrollHeight - prev.scrollHeight;
|
||||||
if (delta > 0) {
|
if (delta > 0) {
|
||||||
container.scrollTop = container.scrollTop + delta;
|
const target = container.scrollTop + delta;
|
||||||
|
if (isMobileSurfaceRuntime()) {
|
||||||
|
setScrollTopDefeatingMomentum(container, target);
|
||||||
|
} else {
|
||||||
|
container.scrollTop = target;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else if (snap) {
|
||||||
|
// setIsLoadingOlder/historyMeta can commit before the server page
|
||||||
|
// arrives. Keep the snapshot armed, but refresh it so later fallback
|
||||||
|
// compensation only accounts for rows actually prepended above.
|
||||||
|
refreshPendingSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTracking();
|
updateTracking();
|
||||||
}, [renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]);
|
}, [captureViewportAnchor, messageListRef, renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]);
|
||||||
|
|
||||||
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => false, []);
|
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => false, []);
|
||||||
|
|
||||||
@@ -462,9 +607,12 @@ export const useChatTimelineController = ({
|
|||||||
// compensate synchronously when React commits the new messages.
|
// compensate synchronously when React commits the new messages.
|
||||||
if (input.preserveViewport && container) {
|
if (input.preserveViewport && container) {
|
||||||
prePrependScrollRef.current = {
|
prePrependScrollRef.current = {
|
||||||
|
sessionId: sessionIdRef.current,
|
||||||
height: container.scrollHeight,
|
height: container.scrollHeight,
|
||||||
top: container.scrollTop,
|
top: container.scrollTop,
|
||||||
anchor: captureViewportAnchor(),
|
anchor: captureViewportAnchor(),
|
||||||
|
oldestId: beforeOldestMessageId,
|
||||||
|
newestId: beforeMessages[beforeMessages.length - 1]?.info?.id ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,6 +622,7 @@ export const useChatTimelineController = ({
|
|||||||
try {
|
try {
|
||||||
const targetSessionId = sessionIdRef.current;
|
const targetSessionId = sessionIdRef.current;
|
||||||
if (!targetSessionId) {
|
if (!targetSessionId) {
|
||||||
|
prePrependScrollRef.current = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,6 +634,7 @@ export const useChatTimelineController = ({
|
|||||||
while (true) {
|
while (true) {
|
||||||
await loadMoreMessages(targetSessionId, 'up');
|
await loadMoreMessages(targetSessionId, 'up');
|
||||||
if (sessionIdRef.current !== targetSessionId) {
|
if (sessionIdRef.current !== targetSessionId) {
|
||||||
|
prePrependScrollRef.current = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +656,7 @@ export const useChatTimelineController = ({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!messageGrowth) {
|
if (!messageGrowth) {
|
||||||
|
prePrependScrollRef.current = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!historySignalsRef.current.hasMoreAboveTurns) {
|
if (!historySignalsRef.current.hasMoreAboveTurns) {
|
||||||
@@ -516,6 +667,9 @@ export const useChatTimelineController = ({
|
|||||||
loadedOldestMessageId = afterOldestMessageId;
|
loadedOldestMessageId = afterOldestMessageId;
|
||||||
loadedLimit = afterLimit;
|
loadedLimit = afterLimit;
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
prePrependScrollRef.current = null;
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingOlder(false);
|
setIsLoadingOlder(false);
|
||||||
settleHistoryInteraction();
|
settleHistoryInteraction();
|
||||||
@@ -547,6 +701,10 @@ export const useChatTimelineController = ({
|
|||||||
}, [loadEarlier, scrollRef]);
|
}, [loadEarlier, scrollRef]);
|
||||||
|
|
||||||
const loadEarlierIfPinnedViewportUnderfilled = React.useCallback(() => {
|
const loadEarlierIfPinnedViewportUnderfilled = React.useCallback(() => {
|
||||||
|
// On mobile the initial page is intentionally smaller. Auto-prepending
|
||||||
|
// older rows after first paint shifts the narrow timeline; let explicit
|
||||||
|
// upward scroll request history instead.
|
||||||
|
if (isMobileSurfaceRuntime()) return;
|
||||||
if (historyInteractionRef.current) return;
|
if (historyInteractionRef.current) return;
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
let markdownRendererModulePromise: Promise<typeof import('./MarkdownRendererImpl')> | null = null;
|
||||||
|
|
||||||
|
export const loadMarkdownRendererModule = () => {
|
||||||
|
markdownRendererModulePromise ??= import('./MarkdownRendererImpl').catch((error) => {
|
||||||
|
markdownRendererModulePromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return markdownRendererModulePromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const preloadMarkdownRenderer = () => {
|
||||||
|
void loadMarkdownRendererModule().catch(() => undefined);
|
||||||
|
};
|
||||||
@@ -399,7 +399,12 @@ export function useSync() {
|
|||||||
complete: merged.complete,
|
complete: merged.complete,
|
||||||
loading: false,
|
loading: false,
|
||||||
})
|
})
|
||||||
store.setState({ message: materialized.message, part: materialized.part })
|
if (materialized.messagesChanged || materialized.partsChanged) {
|
||||||
|
store.setState({
|
||||||
|
...(materialized.messagesChanged ? { message: materialized.message } : {}),
|
||||||
|
...(materialized.partsChanged ? { part: materialized.part } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
setSessionPrefetch({
|
setSessionPrefetch({
|
||||||
directory,
|
directory,
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -498,13 +503,12 @@ export function useSync() {
|
|||||||
shouldLoadMessages ? loadMessages(sessionID, { isStale }) : Promise.resolve(),
|
shouldLoadMessages ? loadMessages(sessionID, { isStale }) : Promise.resolve(),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Progressive mount: after the initial page resolves, if the session
|
// Progressive mount on desktop: after the initial page resolves, if the
|
||||||
// isn't stale and the server indicated more messages, dispatch a
|
// session isn't stale and the server indicated more messages, dispatch a
|
||||||
// second fetch to prepend older history. The user sees the first page
|
// second fetch to prepend older history. Mobile avoids this background
|
||||||
// immediately; the rest arrive shortly after. This gives the scroll
|
// prepend because adding rows after first paint on a narrow viewport can
|
||||||
// container headroom above the viewport so the "load older on
|
// visibly shift the timeline; user scroll still loads older history.
|
||||||
// scroll-up" trigger fires before the user hits the absolute top.
|
if (!isStale() && !isMobileSurfaceRuntime()) {
|
||||||
if (!isStale()) {
|
|
||||||
const currentMeta = getMetaFor(sessionID)
|
const currentMeta = getMetaFor(sessionID)
|
||||||
if (currentMeta.cursor && !currentMeta.complete) {
|
if (currentMeta.cursor && !currentMeta.complete) {
|
||||||
loadMessages(sessionID, { before: currentMeta.cursor, mode: "prepend", isStale })
|
loadMessages(sessionID, { before: currentMeta.cursor, mode: "prepend", isStale })
|
||||||
|
|||||||
Reference in New Issue
Block a user