diff --git a/packages/ui/src/apps/renderMobileApp.tsx b/packages/ui/src/apps/renderMobileApp.tsx
index 64f77954..3c2beeaf 100644
--- a/packages/ui/src/apps/renderMobileApp.tsx
+++ b/packages/ui/src/apps/renderMobileApp.tsx
@@ -16,6 +16,7 @@ import { initializeLocale, I18nProvider } from '@/lib/i18n';
import { initializeAppearancePreferences, syncDesktopSettings } from '@/lib/persistence';
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
import { startTypographyWatcher } from '@/lib/typographyWatcher';
+import { preloadMarkdownRenderer } from '@/components/chat/markdownRendererLoader';
import { MobileApp } from './MobileApp';
const initializeSharedPreferences = () => {
@@ -42,6 +43,7 @@ const initializeSharedPreferences = () => {
};
export function renderMobileApp(apis: RuntimeAPIs) {
+ preloadMarkdownRenderer();
initializeSharedPreferences();
// Expose the widget snapshot builder so the native shell can read the session overview
diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx
index 4d56736b..c294bd6f 100644
--- a/packages/ui/src/components/chat/MarkdownRenderer.tsx
+++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx
@@ -1,5 +1,8 @@
import React from 'react';
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.
// The full implementation (marked + Shiki highlighting + KaTeX + morphdom
@@ -7,23 +10,41 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
// initial bundle lean.
const MarkdownRendererLazy = lazyWithChunkRecovery(() =>
- import('./MarkdownRendererImpl').then((m) => ({ default: m.MarkdownRenderer }))
+ loadMarkdownRendererModule().then((m) => ({ default: m.MarkdownRenderer }))
);
const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() =>
- import('./MarkdownRendererImpl').then((m) => ({ default: m.SimpleMarkdownRenderer }))
+ loadMarkdownRendererModule().then((m) => ({ default: m.SimpleMarkdownRenderer }))
);
const fallback =
;
+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 (
+
+ {props.content}
+
+ );
+};
+
export const MarkdownRenderer: React.FC> = (props) => (
-
+ }>
);
export const SimpleMarkdownRenderer: React.FC> = (props) => (
-
+ }>
);
diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx
index 653f370d..73bcea09 100644
--- a/packages/ui/src/components/chat/MessageList.tsx
+++ b/packages/ui/src/components/chat/MessageList.tsx
@@ -155,6 +155,21 @@ const getMessageParentId = (message: ChatMessageEntry): string | 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 => {
if (!message) return false;
if (resolveMessageRole(message) !== 'user') return false;
@@ -373,6 +388,7 @@ export interface MessageListHandle {
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean;
captureViewportAnchor: () => { messageId: string; offsetTop: number } | null;
restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean;
+ isHistoryVirtualized: () => boolean;
scrollToBottom: () => void;
}
@@ -1262,7 +1278,10 @@ const MessageList = React.forwardRef(({
}
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 virtualCache = React.useMemo(
() => (shouldVirtualizeHistory ? readTimelineCache(sessionKey, historyEntryKeys) : undefined),
@@ -1468,6 +1487,8 @@ const MessageList = React.forwardRef(({
);
},
+ isHistoryVirtualized: () => shouldVirtualizeHistory,
+
captureViewportAnchor: () => {
const container = resolveScrollContainer();
if (!container) {
@@ -1486,9 +1507,7 @@ const MessageList = React.forwardRef(({
return true;
}
- const computed = window.getComputedStyle(node);
- const isStuckSticky = computed.position === 'sticky' && rect.top <= containerRect.top + 1;
- return !isStuckSticky;
+ return !isInsideStuckSticky(node, container, containerRect.top);
}) ?? nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1);
if (!firstVisible) {
return null;
diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts
index 7c45cd4f..4eb0a72e 100644
--- a/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts
+++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, test } from 'bun:test';
-import { shouldAutoLoadEarlierForUnderfilledPinnedViewport } from './useChatTimelineController';
+import {
+ isOlderHistoryPrependCommit,
+ shouldAutoLoadEarlierForUnderfilledPinnedViewport,
+} from './useChatTimelineController';
const baseInput = {
sessionId: 'ses_1',
@@ -39,3 +42,29 @@ describe('shouldAutoLoadEarlierForUnderfilledPinnedViewport', () => {
})).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);
+ });
+});
diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts
index 13a0793c..528eafa4 100644
--- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts
+++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts
@@ -126,6 +126,75 @@ export const shouldAutoLoadEarlierForUnderfilledPinnedViewport = (input: {
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 = ({
sessionId,
messages,
@@ -339,9 +408,12 @@ export const useChatTimelineController = ({
// before triggering the state change. useLayoutEffect consumes it
// after React commits new DOM — before the browser paints.
const prePrependScrollRef = React.useRef<{
+ sessionId: string | null;
height: number;
top: number;
anchor: ViewportAnchor | null;
+ oldestId: string | null;
+ newestId: string | null;
} | null>(null);
const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => {
@@ -364,26 +436,47 @@ export const useChatTimelineController = ({
scrollHeight: number;
} | null>(null);
+ React.useLayoutEffect(() => {
+ prePrependScrollRef.current = null;
+ prependTrackingRef.current = null;
+ }, [sessionId]);
+
React.useLayoutEffect(() => {
const container = scrollRef.current;
if (!container) return;
- const snap = prePrependScrollRef.current;
+ let snap = prePrependScrollRef.current;
const prev = prependTrackingRef.current;
const currentOldestId = renderedMessages[0]?.info?.id ?? null;
const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null;
- // A prepend = content inserted ABOVE the viewport: the oldest message id
- // changed while the newest stayed the same. This distinguishes a history
- // load from a bottom append, a streaming part growing, or a session switch.
- const isPrepend = Boolean(
- prev
- && prev.oldestId
- && currentOldestId
- && currentOldestId !== prev.oldestId
- && prev.newestId
- && currentNewestId
- && currentNewestId === prev.newestId,
- );
+ // A prepend = content inserted ABOVE the viewport: either the newest
+ // stayed fixed, or the old first message still exists below a new first
+ // message. The latter keeps preservation alive if a tail append lands in
+ // the same commit as the history page.
+ const isPrepend = prev
+ ? isOlderHistoryPrependCommit({
+ previousOldestId: prev.oldestId,
+ previousNewestId: prev.newestId,
+ currentOldestId,
+ currentNewestId,
+ }) || 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 = () => {
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) {
// Bottom-pinned. Only content inserted ABOVE (a prepend / history load)
// 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
// queue / while streaming. So for an append we do nothing and let
// auto-follow own it.
- if (snap || isPrepend) {
+ if (didPrepend) {
prePrependScrollRef.current = null;
goToBottom('instant');
+ } else if (snap) {
+ refreshPendingSnapshot();
}
updateTracking();
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;
+ 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
// 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.
if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) {
// Fallback: height-delta compensation
- const delta = container.scrollHeight - snap.height;
- if (delta > 0) {
- container.scrollTop = snap.top + delta;
- }
+ applyHeightDelta();
}
- } else if (isPrepend && prev) {
+ } 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
- // auto-follow to fight.
+ // auto-follow to fight. Virtualized lists skip this — virtua `shift`
+ // already compensated the prepend.
const delta = container.scrollHeight - prev.scrollHeight;
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();
- }, [renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]);
+ }, [captureViewportAnchor, messageListRef, renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]);
const revealBufferedTurns = React.useCallback(async (): Promise => false, []);
@@ -462,9 +607,12 @@ export const useChatTimelineController = ({
// compensate synchronously when React commits the new messages.
if (input.preserveViewport && container) {
prePrependScrollRef.current = {
+ sessionId: sessionIdRef.current,
height: container.scrollHeight,
top: container.scrollTop,
anchor: captureViewportAnchor(),
+ oldestId: beforeOldestMessageId,
+ newestId: beforeMessages[beforeMessages.length - 1]?.info?.id ?? null,
};
}
@@ -474,6 +622,7 @@ export const useChatTimelineController = ({
try {
const targetSessionId = sessionIdRef.current;
if (!targetSessionId) {
+ prePrependScrollRef.current = null;
return false;
}
@@ -485,6 +634,7 @@ export const useChatTimelineController = ({
while (true) {
await loadMoreMessages(targetSessionId, 'up');
if (sessionIdRef.current !== targetSessionId) {
+ prePrependScrollRef.current = null;
return false;
}
@@ -506,6 +656,7 @@ export const useChatTimelineController = ({
return true;
}
if (!messageGrowth) {
+ prePrependScrollRef.current = null;
return false;
}
if (!historySignalsRef.current.hasMoreAboveTurns) {
@@ -516,6 +667,9 @@ export const useChatTimelineController = ({
loadedOldestMessageId = afterOldestMessageId;
loadedLimit = afterLimit;
}
+ } catch (error) {
+ prePrependScrollRef.current = null;
+ throw error;
} finally {
setIsLoadingOlder(false);
settleHistoryInteraction();
@@ -547,6 +701,10 @@ export const useChatTimelineController = ({
}, [loadEarlier, scrollRef]);
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;
const container = scrollRef.current;
if (!container) return;
diff --git a/packages/ui/src/components/chat/markdownRendererLoader.ts b/packages/ui/src/components/chat/markdownRendererLoader.ts
new file mode 100644
index 00000000..986fbedc
--- /dev/null
+++ b/packages/ui/src/components/chat/markdownRendererLoader.ts
@@ -0,0 +1,13 @@
+let markdownRendererModulePromise: Promise | null = null;
+
+export const loadMarkdownRendererModule = () => {
+ markdownRendererModulePromise ??= import('./MarkdownRendererImpl').catch((error) => {
+ markdownRendererModulePromise = null;
+ throw error;
+ });
+ return markdownRendererModulePromise;
+};
+
+export const preloadMarkdownRenderer = () => {
+ void loadMarkdownRendererModule().catch(() => undefined);
+};
diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts
index b0d646d6..6271ffde 100644
--- a/packages/ui/src/sync/use-sync.ts
+++ b/packages/ui/src/sync/use-sync.ts
@@ -399,7 +399,12 @@ export function useSync() {
complete: merged.complete,
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({
directory,
sessionID,
@@ -498,13 +503,12 @@ export function useSync() {
shouldLoadMessages ? loadMessages(sessionID, { isStale }) : Promise.resolve(),
])
- // Progressive mount: 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. The user sees the first page
- // immediately; the rest arrive shortly after. This gives the scroll
- // container headroom above the viewport so the "load older on
- // scroll-up" trigger fires before the user hits the absolute top.
- if (!isStale()) {
+ // 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.
+ if (!isStale() && !isMobileSurfaceRuntime()) {
const currentMeta = getMetaFor(sessionID)
if (currentMeta.cursor && !currentMeta.complete) {
loadMessages(sessionID, { before: currentMeta.cursor, mode: "prepend", isStale })