diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx
index 9174668b..260328c9 100644
--- a/packages/ui/src/components/chat/ChatContainer.tsx
+++ b/packages/ui/src/components/chat/ChatContainer.tsx
@@ -361,10 +361,6 @@ const ChatViewport = React.memo(({
-
-
-
-
>
), [currentSessionId, directory, isMobile, sessionPermissions, sessionQuestions]);
@@ -417,6 +413,13 @@ const ChatViewport = React.memo(({
scrollContainerProps={scrollContainerProps}
/>
+ {/* Static above the composer: inside the list it walked down
+ with every streamed line while a turn was anchored. */}
+
{showPromptNavigator && promptTurnIds.length >= 2 ? (
= ({
...additionalParts.flatMap(p => p.attachments ?? []),
];
+ // Arm the timeline anchor BEFORE the optimistic user row can commit;
+ // arming after (or a frame later) races the commit and the anchor
+ // never claims the new message.
+ scrollToBottom?.();
+
const sendPromise = sendMessage(
primaryText,
providerIdToSend,
@@ -1307,14 +1312,6 @@ const ChatInputComponent: React.FC = ({
}
};
- if (typeof window === 'undefined') {
- scrollToBottom?.();
- } else {
- window.requestAnimationFrame(() => {
- scrollToBottom?.();
- });
- }
-
void sendPromise.then(() => {
// Record what this session was pointed at, so the work-status panel
// can show it as a context source long after the message scrolled
diff --git a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts
index 0a1acde3..9c5a8ae1 100644
--- a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts
+++ b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts
@@ -184,12 +184,14 @@ describe('getAnchoredTurnMetrics', () => {
});
describe('resolveTimelineIsAtEnd', () => {
- test('prefers the near-end threshold over the exact content bottom', () => {
- expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
- expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false);
+ test('uses a tight distance band against the full content length', () => {
+ expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
+ expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1365, scrollLength: 600 })).toBe(true);
+ expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1300, scrollLength: 600 })).toBe(false);
});
- test('falls back to the exact end when near-end is unavailable', () => {
+ test('falls back to the list flags when distances are unavailable', () => {
+ expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
});
diff --git a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts
index 2344f0f3..cd670a1f 100644
--- a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts
+++ b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts
@@ -108,15 +108,35 @@ export const getAnchoredTurnMetrics = ({
};
};
-// "At the end" for follow purposes is the NEAR-end threshold, not the exact
-// content bottom: the timeline's footer (status row plus bottom spacer) sits
-// below the last row, so requiring the exact bottom would drop out of follow —
-// and pop the scroll-to-bottom pill — while the user is still looking at the
-// live edge. `isAtEnd` is only the fallback for states that predate the
-// near-end signal.
+// "At the end" for follow purposes is a tight band, not the list's isNearEnd
+// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
+// follow while the user had genuinely scrolled away, yanking them back on the
+// next stream chunk. Distance is measured against the full content length —
+// reserved anchored end space included — so a parked anchored turn counts as
+// the live edge.
+export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
+
export const resolveTimelineIsAtEnd = (
- state: { readonly isNearEnd?: boolean; readonly isAtEnd?: boolean } | undefined,
-): boolean | undefined => state?.isNearEnd ?? state?.isAtEnd;
+ state: {
+ readonly contentLength?: number;
+ readonly scroll?: number;
+ readonly scrollLength?: number;
+ readonly isNearEnd?: boolean;
+ readonly isAtEnd?: boolean;
+ } | undefined,
+): boolean | undefined => {
+ if (!state) return undefined;
+ const { contentLength, scroll, scrollLength } = state;
+ if (
+ typeof contentLength === 'number'
+ && typeof scroll === 'number'
+ && typeof scrollLength === 'number'
+ && Number.isFinite(contentLength)
+ ) {
+ return contentLength - (scroll + scrollLength) <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
+ }
+ return state.isNearEnd ?? state.isAtEnd;
+};
export interface ChatListAnchoredEndSpace {
readonly anchorIndex: number;
diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts
index a957650a..038fc1d4 100644
--- a/packages/ui/src/hooks/useChatTimelineScroll.ts
+++ b/packages/ui/src/hooks/useChatTimelineScroll.ts
@@ -136,6 +136,9 @@ export const useChatTimelineScroll = ({
// it may load older pages without disturbing the read position.
const [isPinned, setIsPinned] = React.useState(true);
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
+ // True after a real gesture until an explicit opt back in; drives the
+ // overlay scrollbar suppression instead of the anchor's mere existence.
+ const [userOwnsScroll, setUserOwnsScroll] = React.useState(false);
const modeRef = React.useRef('following-end');
const isAtEndRef = React.useRef(true);
@@ -203,13 +206,25 @@ export const useChatTimelineScroll = ({
setAnchorMessageId(null);
}, []);
- // A real gesture: stop every automatic movement until the user opts back in.
+ // A real gesture: stop every automatic movement until the user opts back
+ // in. The anchored END SPACE stays — collapsing it mid-gesture clamps the
+ // viewport back to the end — only the anchor machinery is disarmed.
const onManualNavigation = React.useCallback(() => {
userGenerationRef.current += 1;
modeRef.current = 'free-scrolling';
liveFollowGenerationRef.current = null;
- clearAnchor();
- }, [clearAnchor]);
+ setUserOwnsScroll(true);
+ armedForNextUserMessageRef.current = false;
+ pendingAnchorRef.current = null;
+ positionedAnchorRef.current = null;
+ settledAnchorRef.current = null;
+ activeAnchorIndexRef.current = null;
+ pendingAnchorRestoreRef.current = null;
+ if (anchorRestoreFrameRef.current !== null) {
+ cancelAnimationFrame(anchorRestoreFrameRef.current);
+ anchorRestoreFrameRef.current = null;
+ }
+ }, []);
const isLiveFollowActive = React.useCallback(() => (
liveFollowGenerationRef.current === userGenerationRef.current
@@ -267,6 +282,7 @@ export const useChatTimelineScroll = ({
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
isAtEndRef.current = true;
setIsPinned(true);
+ setUserOwnsScroll(false);
modeRef.current = 'following-end';
// Returning to the end is an explicit opt back IN to live follow.
liveFollowGenerationRef.current = userGenerationRef.current;
@@ -279,9 +295,14 @@ export const useChatTimelineScroll = ({
// row is created by the store), so the next new user message id claims it.
const scrollToBottomOnSend = React.useCallback(() => {
isAtEndRef.current = true;
+ setUserOwnsScroll(false);
modeRef.current = 'anchoring-new-turn';
liveFollowGenerationRef.current = userGenerationRef.current;
armedForNextUserMessageRef.current = true;
+ // The optimistic row is not committed yet; the next NEW user message id
+ // relative to this baseline claims the anchor, independent of whether
+ // the commit lands before or after this call.
+ armBaselineUserMessageIdRef.current = lastArmedUserMessageIdRef.current;
pendingAnchorRef.current = null;
positionedAnchorRef.current = null;
settledAnchorRef.current = null;
@@ -289,13 +310,16 @@ export const useChatTimelineScroll = ({
hideScrollButton();
}, [hideScrollButton]);
- // Claim the anchor as soon as the sent row exists in the timeline.
+ // Claim the anchor as soon as the sent row exists in the timeline. The
+ // comparison is against the baseline captured when the send armed the
+ // anchor, so the claim works whether the optimistic row committed before
+ // or after the arming call.
const lastArmedUserMessageIdRef = React.useRef(lastUserMessageId);
+ const armBaselineUserMessageIdRef = React.useRef(lastUserMessageId);
React.useEffect(() => {
- const previous = lastArmedUserMessageIdRef.current;
lastArmedUserMessageIdRef.current = lastUserMessageId;
if (!armedForNextUserMessageRef.current) return;
- if (!lastUserMessageId || lastUserMessageId === previous) return;
+ if (!lastUserMessageId || lastUserMessageId === armBaselineUserMessageIdRef.current) return;
armedForNextUserMessageRef.current = false;
pendingAnchorRef.current = lastUserMessageId;
setAnchorMessageId(lastUserMessageId);
@@ -308,6 +332,7 @@ export const useChatTimelineScroll = ({
// Entering a session always returns to the live edge. Late async growth
// is handled by the list staying at the end, not by a timed hold.
isAtEndRef.current = true;
+ setUserOwnsScroll(false);
modeRef.current = 'following-end';
liveFollowGenerationRef.current = userGenerationRef.current;
clearAnchor();
@@ -336,8 +361,11 @@ export const useChatTimelineScroll = ({
isAtEndRef.current = isAtEnd;
setIsPinned(isAtEnd);
if (isAtEnd) {
- modeRef.current = 'following-end';
+ if (modeRef.current !== 'anchoring-new-turn') {
+ modeRef.current = 'following-end';
+ }
liveFollowGenerationRef.current = userGenerationRef.current;
+ setUserOwnsScroll(false);
hideScrollButton();
} else {
modeRef.current = 'free-scrolling';
@@ -349,6 +377,10 @@ export const useChatTimelineScroll = ({
// Park the anchored row near the top once the list has measured it.
const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => {
+ // The anchored end space can be remeasured long after the send (turn
+ // completion, images decoding). Only the send-time anchoring mode may
+ // position the viewport.
+ if (modeRef.current !== 'anchoring-new-turn') return;
if (pendingAnchorRef.current === messageId) {
pendingAnchorRef.current = null;
}
@@ -404,7 +436,7 @@ export const useChatTimelineScroll = ({
// against sub-pixel drift and only while the user has not taken over.
const onAnchorSizeChanged = React.useCallback((messageId: string) => {
if (settledAnchorRef.current !== messageId) return;
- if (isLiveFollowActive()) return;
+ if (!isLiveFollowActive()) return;
const scrollOffset = listRef.current?.getState().scroll;
if (scrollOffset === undefined) return;
@@ -530,25 +562,48 @@ export const useChatTimelineScroll = ({
React.useEffect(() => {
if (!scrollNode) return;
- const handleGesture = () => {
+ const contentScrollsUp = () => {
+ const list = listRef.current;
+ return list ? realContentOverflowsViewport(list) : false;
+ };
+ const gesture = () => {
onManualNavigationRef.current();
};
+ const handleWheel = (event: WheelEvent) => {
+ // Scrolling toward the end is not opting out of follow.
+ if (event.deltaY < 0 && contentScrollsUp()) gesture();
+ };
+ const handleTouchMove = () => {
+ if (!isAtEndRef.current && contentScrollsUp()) gesture();
+ };
+ const handlePointerDown = (event: PointerEvent) => {
+ // The scrollbar track is the scroll node itself; a tap on a row
+ // only breaks follow when the viewport already left the end.
+ if ((event.target === scrollNode || !isAtEndRef.current) && contentScrollsUp()) gesture();
+ };
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if ((event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') && contentScrollsUp()) {
+ gesture();
+ }
+ };
const handleScroll = () => {
queueSave();
};
- scrollNode.addEventListener('wheel', handleGesture, { passive: true });
- scrollNode.addEventListener('touchmove', handleGesture, { passive: true });
- scrollNode.addEventListener('pointerdown', handleGesture, { passive: true });
+ scrollNode.addEventListener('wheel', handleWheel, { passive: true });
+ scrollNode.addEventListener('touchmove', handleTouchMove, { passive: true });
+ scrollNode.addEventListener('pointerdown', handlePointerDown, { passive: true });
+ scrollNode.addEventListener('keydown', handleKeyDown);
scrollNode.addEventListener('scroll', handleScroll, { passive: true });
return () => {
- scrollNode.removeEventListener('wheel', handleGesture);
- scrollNode.removeEventListener('touchmove', handleGesture);
- scrollNode.removeEventListener('pointerdown', handleGesture);
+ scrollNode.removeEventListener('wheel', handleWheel);
+ scrollNode.removeEventListener('touchmove', handleTouchMove);
+ scrollNode.removeEventListener('pointerdown', handlePointerDown);
+ scrollNode.removeEventListener('keydown', handleKeyDown);
scrollNode.removeEventListener('scroll', handleScroll);
};
- }, [queueSave, scrollNode]);
+ }, [queueSave, realContentOverflowsViewport, scrollNode]);
// ── session lifecycle ───────────────────────────────────────────────────
const lastSessionKeyRef = React.useRef(null);
@@ -561,6 +616,7 @@ export const useChatTimelineScroll = ({
// Persist the outgoing session's position before the new one takes over.
flushSave();
isAtEndRef.current = true;
+ setUserOwnsScroll(false);
modeRef.current = 'following-end';
liveFollowGenerationRef.current = userGenerationRef.current;
clearAnchor();
@@ -570,8 +626,8 @@ export const useChatTimelineScroll = ({
// Suppress the overlay scrollbar thumb while automatic movement owns the
// scroll position, so it does not jump on each correction.
React.useEffect(() => {
- setIsFollowingProgrammatically(!showScrollButton && anchorMessageId === null);
- }, [anchorMessageId, showScrollButton]);
+ setIsFollowingProgrammatically(!showScrollButton && !userOwnsScroll);
+ }, [showScrollButton, userOwnsScroll]);
React.useEffect(() => () => {
cancelShowButtonTimer();