fix(web-shell): only show scroll-to-bottom button when content overflows (#6150)

* fix(web-shell): simplify scroll bottom visibility

* fix(web-shell): stabilize scroll bottom affordance

* fix(web-shell): align bottom follow threshold

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>
This commit is contained in:
ytahdn 2026-07-02 14:29:57 +08:00 committed by GitHub
parent 55e425c1d2
commit bf8bdd9e76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 397 additions and 34 deletions

View file

@ -1040,12 +1040,12 @@ export function App({
const editorRef = useRef<EditorHandle | null>(null);
const notifiedComposerReadyRef = useRef<EditorHandle | null>(null);
const footerRef = useRef<HTMLDivElement>(null);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [canScrollMessageListToBottom, setCanScrollMessageListToBottom] =
useState(false);
const previousFooterRectRef = useRef<DOMRect | null>(null);
const previousEmptyStateRef = useRef(false);
const resumeChatBottomFollow = useCallback(
(behavior: ScrollBehavior = 'smooth') => {
setShowScrollToBottom(false);
requestAnimationFrame(() => {
messageListRef.current?.scrollToBottom(behavior);
requestAnimationFrame(() => {
@ -3066,9 +3066,12 @@ export function App({
}
editorRef.current?.focus();
}, []);
const handleFollowStateChange = useCallback((isFollowing: boolean) => {
setShowScrollToBottom(!isFollowing);
}, []);
const handleCanScrollToBottomChange = useCallback(
(canScrollToBottom: boolean) => {
setCanScrollMessageListToBottom(canScrollToBottom);
},
[],
);
const handleRetry = useCallback(() => {
if (
@ -3764,7 +3767,9 @@ export function App({
}
tailContent={undefined}
tailKey={undefined}
onFollowStateChange={handleFollowStateChange}
onCanScrollToBottomChange={
handleCanScrollToBottomChange
}
virtualScrollThreshold={virtualScrollThreshold}
/>
{btwMessage?.role === 'btw' && (
@ -3781,7 +3786,7 @@ export function App({
</CompactModeContext.Provider>
<div ref={footerRef} className={styles.footer}>
{showScrollToBottom && (
{canScrollMessageListToBottom && (
<div
className={
showFloatingTodos

View file

@ -21,10 +21,19 @@ vi.mock('./MessageItem', async () => {
message: Message;
showAssistantActions?: boolean;
}) =>
React.createElement('div', {
'data-testid': `msg-${message.id}`,
'data-assistant-actions': String(Boolean(showAssistantActions)),
}),
React.createElement(
'div',
{
'data-testid': `msg-${message.id}`,
'data-assistant-actions': String(Boolean(showAssistantActions)),
},
message.role === 'thinking'
? React.createElement('button', {
'aria-expanded': 'false',
'data-testid': `disclosure-${message.id}`,
})
: null,
),
};
});
vi.mock('./messages/tools/ParallelAgentsGroup', () => ({
@ -42,8 +51,11 @@ type MessageListHandle = import('./MessageList').MessageListHandle;
// jsdom provides neither ResizeObserver (MessageList's resize guard) nor a real
// scrollIntoView (the non-virtual scroll path) — stub both.
const resizeObserverCallbacks: ResizeObserverCallback[] = [];
class ResizeObserverStub {
constructor(private readonly callback: ResizeObserverCallback) {}
constructor(private readonly callback: ResizeObserverCallback) {
resizeObserverCallbacks.push(callback);
}
observe() {
this.callback([], this as unknown as ResizeObserver);
}
@ -56,12 +68,19 @@ if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
function triggerResizeObservers() {
for (const callback of resizeObserverCallbacks) {
callback([], {} as ResizeObserver);
}
}
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
resizeObserverCallbacks.length = 0;
vi.useRealTimers();
});
@ -107,7 +126,10 @@ const planMsg = (id: string): PlanMessage => ({
function mount(
messages: Message[],
ref?: RefObject<MessageListHandle | null>,
opts: { isResponding?: boolean } = {},
opts: {
isResponding?: boolean;
onCanScrollToBottomChange?: (canScrollToBottom: boolean) => void;
} = {},
): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
@ -121,6 +143,7 @@ function mount(
pendingApproval={null}
isResponding={opts.isResponding}
shellOutputMaxLines={50}
onCanScrollToBottomChange={opts.onCanScrollToBottomChange}
/>
</I18nProvider>,
);
@ -143,6 +166,8 @@ const queryToggle = (c: HTMLElement, turnId: string) =>
c.querySelector(`[data-testid="toggle-${turnId}"]`) as HTMLElement | null;
const toggle = (c: HTMLElement, turnId: string) =>
queryToggle(c, turnId) as HTMLElement;
const disclosure = (c: HTMLElement, id: string) =>
c.querySelector(`[data-testid="disclosure-${id}"]`) as HTMLElement;
const toggleRow = (c: HTMLElement, turnId: string) =>
toggle(c, turnId).closest('[role="button"]') as HTMLElement;
const click = (el: Element) =>
@ -427,4 +452,255 @@ describe('MessageList — turn collapse (DOM)', () => {
expect(assistantActions(c, 'mid')).toBe('false');
expect(assistantActions(c, 'a1')).toBe('true');
});
it('reports when the user has scrolled away from the bottom', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
value: 600,
writable: true,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: vi.fn(),
});
const onCanScrollToBottomChange = vi.fn();
const container = mount([asstMsg('a1')], undefined, {
onCanScrollToBottomChange,
});
await nextFrame();
const list = container.firstElementChild as HTMLElement;
list.scrollTop = 600;
act(() => list.dispatchEvent(new Event('scroll', { bubbles: true })));
await nextFrame();
list.scrollTop = 500;
act(() => list.dispatchEvent(new Event('scroll', { bubbles: true })));
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(true);
list.scrollTop = 600;
act(() => list.dispatchEvent(new Event('scroll', { bubbles: true })));
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('reports no scroll-to-bottom affordance when the list has no scrollbar', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
const onCanScrollToBottomChange = vi.fn();
mount([userMsg('u1')], undefined, { onCanScrollToBottomChange });
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('reports no scroll-to-bottom affordance when already at the bottom', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
value: 600,
writable: true,
});
const onCanScrollToBottomChange = vi.fn();
mount([userMsg('u1')], undefined, { onCanScrollToBottomChange });
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('keeps the scroll-to-bottom affordance hidden when followed content grows', async () => {
let scrollHeight = 600;
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = Math.max(0, Math.min(value, scrollHeight - 600));
},
});
const onCanScrollToBottomChange = vi.fn();
mount([asstMsg('a1')], undefined, { onCanScrollToBottomChange });
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
scrollHeight = 1200;
act(() => triggerResizeObservers());
await nextFrame();
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('reports scroll-to-bottom affordance when a clicked disclosure grows during streaming', async () => {
let scrollHeight = 600;
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = Math.max(0, Math.min(value, scrollHeight - 600));
},
});
const onCanScrollToBottomChange = vi.fn();
const c = mount([thinkingMsg('t1'), asstMsg('a1')], undefined, {
isResponding: true,
onCanScrollToBottomChange,
});
await nextFrame();
click(disclosure(c, 't1'));
scrollHeight = 1200;
act(() => triggerResizeObservers());
await nextFrame();
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(true);
});
it('keeps the scroll-to-bottom affordance hidden when disclosure growth stays near bottom', async () => {
let scrollHeight = 600;
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = Math.max(0, Math.min(value, scrollHeight - 600));
},
});
const onCanScrollToBottomChange = vi.fn();
const c = mount([thinkingMsg('t1'), asstMsg('a1')], undefined, {
isResponding: true,
onCanScrollToBottomChange,
});
await nextFrame();
click(disclosure(c, 't1'));
scrollHeight = 620;
act(() => triggerResizeObservers());
await nextFrame();
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('clears the scroll-to-bottom affordance immediately after scrolling to bottom', async () => {
let scrollTop = 600;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = Math.max(0, Math.min(value, 600));
},
});
const onCanScrollToBottomChange = vi.fn();
const ref = createRef<MessageListHandle>();
const c = mount([asstMsg('a1')], ref, { onCanScrollToBottomChange });
await nextFrame();
await nextFrame();
const list = c.firstElementChild as HTMLElement;
scrollTop = 0;
act(() => list.dispatchEvent(new Event('scroll', { bubbles: true })));
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(true);
act(() => ref.current?.scrollToBottom('auto'));
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('reports scroll-to-bottom affordance when expanding content creates overflow', async () => {
let scrollHeight = 600;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
value: 0,
writable: true,
});
const onCanScrollToBottomChange = vi.fn();
const c = mount([userMsg('u1'), toolMsg('g1'), asstMsg('a1')], undefined, {
onCanScrollToBottomChange,
});
await nextFrame();
click(toggle(c, 'u1'));
scrollHeight = 1200;
await nextFrame();
await nextFrame();
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 230)));
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(true);
});
});

View file

@ -10,6 +10,7 @@ import {
useMemo,
useState,
type ReactNode,
type MouseEvent as ReactMouseEvent,
type MutableRefObject,
} from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
@ -57,7 +58,7 @@ interface MessageListProps {
showRetryHint?: boolean;
onRetryClick?: () => void;
onBranchSession?: () => void;
onFollowStateChange?: (isFollowing: boolean) => void;
onCanScrollToBottomChange?: (canScrollToBottom: boolean) => void;
}
function getLastUserMessageId(messages: Message[]): string | null {
@ -1240,6 +1241,7 @@ const ESTIMATE_HEADER = 120;
const ESTIMATE_MESSAGE = 80;
const ESTIMATE_TURN_COLLAPSE = 32;
const ESTIMATE_TAIL = 240;
const FOLLOW_BOTTOM_THRESHOLD_PX = 30;
export const VIRTUAL_SCROLL_THRESHOLD = 200;
export function shouldUseVirtualScroll(
@ -1593,7 +1595,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
showRetryHint = false,
onRetryClick,
onBranchSession,
onFollowStateChange,
onCanScrollToBottomChange,
},
ref,
) {
@ -1682,7 +1684,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
const scrollCooldown = useRef(false);
const scrollCooldownCount = useRef(0);
const sessionTimelineFrame = useRef<number | null>(null);
const lastReportedFollow = useRef(true);
const lastReportedCanScrollToBottom = useRef<boolean | null>(null);
const prevLastUserMsgId = useRef<string | null>(null);
const prevActiveExecutionKey = useRef<string | null>(null);
const prevCatchingUp: MutableRefObject<boolean | undefined> =
@ -1692,17 +1694,37 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
const pendingFollowRecheck = useRef(false);
const pendingFollowRecheckFrame = useRef<number | undefined>(undefined);
const pendingFollowRecheckTimer = useRef<number | undefined>(undefined);
const pendingOverflowFrame = useRef<number | undefined>(undefined);
catchingUpRef.current = catchingUp;
const containerRef = useRef<HTMLDivElement>(null);
const reportCanScrollToBottom = useCallback(() => {
const el = containerRef.current;
const distanceFromBottom = el
? el.scrollHeight - el.scrollTop - el.clientHeight
: 0;
const canScrollToBottom = !shouldFollow.current && distanceFromBottom > 1;
if (lastReportedCanScrollToBottom.current === canScrollToBottom) return;
lastReportedCanScrollToBottom.current = canScrollToBottom;
onCanScrollToBottomChange?.(canScrollToBottom);
}, [onCanScrollToBottomChange]);
const scheduleScrollOverflowReport = useCallback(() => {
if (pendingOverflowFrame.current !== undefined) {
window.cancelAnimationFrame(pendingOverflowFrame.current);
}
pendingOverflowFrame.current = window.requestAnimationFrame(
reportCanScrollToBottom,
);
}, [reportCanScrollToBottom]);
const setShouldFollow = useCallback(
(value: boolean) => {
if (shouldFollow.current === value) return;
shouldFollow.current = value;
if (lastReportedFollow.current === value) return;
lastReportedFollow.current = value;
onFollowStateChange?.(value);
scheduleScrollOverflowReport();
},
[onFollowStateChange],
[scheduleScrollOverflowReport],
);
const visibleItems = useMemo(
() =>
@ -1763,7 +1785,8 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
// Even if the model is still streaming, the viewport stays put.
//
// 3. Scroll-back-to-bottom resumes — when the user scrolls back
// near the bottom (< 30px from edge), follow mode re-engages
// near the bottom (within FOLLOW_BOTTOM_THRESHOLD_PX), follow mode
// re-engages
// and new content resumes sticking.
//
// 4. New message resets follow — after the user sends a message,
@ -1812,8 +1835,9 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
if (!el) return;
const distanceFromBottom =
el.scrollHeight - el.scrollTop - el.clientHeight;
setShouldFollow(distanceFromBottom < 30);
}, [setShouldFollow]);
setShouldFollow(distanceFromBottom < FOLLOW_BOTTOM_THRESHOLD_PX);
scheduleScrollOverflowReport();
}, [scheduleScrollOverflowReport, setShouldFollow]);
const scheduleFollowRecheck = useCallback(() => {
pendingFollowRecheck.current = true;
@ -1845,6 +1869,9 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
if (pendingFollowRecheckTimer.current !== undefined) {
window.clearTimeout(pendingFollowRecheckTimer.current);
}
if (pendingOverflowFrame.current !== undefined) {
window.cancelAnimationFrame(pendingOverflowFrame.current);
}
},
[],
);
@ -1855,14 +1882,13 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
// follow so streaming output does not yank the viewport back to the
// tail while the user is inspecting history.
const el = containerRef.current;
if (el && el.scrollHeight <= el.clientHeight + 1) {
// If there is no scrollbar yet, there is no meaningful "not at
// bottom" state to report. The toggle may create overflow though, so
// re-check after the expanded/collapsed rows have been laid out.
scheduleFollowRecheck();
} else {
// If there is no scrollbar yet, there is no meaningful "not at
// bottom" state to report. The toggle may create overflow though, so
// re-check after the expanded/collapsed rows have been laid out.
if (!el || el.scrollHeight > el.clientHeight + 1) {
setShouldFollow(false);
}
scheduleFollowRecheck();
setCollapseOverrides((prev) => {
const next = new Map(prev);
next.set(turnId, nextExpanded);
@ -1872,6 +1898,17 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
[scheduleFollowRecheck, setShouldFollow],
);
const handleDisclosureClickCapture = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (!target.closest('[aria-expanded]')) return;
setShouldFollow(false);
scheduleFollowRecheck();
},
[scheduleFollowRecheck, setShouldFollow],
);
const getItemKey = useCallback(
(index: number) => {
if (hasHeader && index === HEADER_INDEX) return 'slot:header';
@ -1905,7 +1942,9 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
} else {
el.scrollTop = el.scrollHeight;
}
scheduleScrollOverflowReport();
lastScrollTop.current = Math.max(0, el.scrollHeight - el.clientHeight);
reportCanScrollToBottom();
const releaseCooldown = () => {
if (scrollCooldownCount.current === gen) {
scrollCooldown.current = false;
@ -1917,7 +1956,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
requestAnimationFrame(releaseCooldown);
}
},
[getScrollElement],
[getScrollElement, reportCanScrollToBottom, scheduleScrollOverflowReport],
);
const resumeBottomFollow = useCallback(
@ -2103,6 +2142,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
if (scrollCooldownCount.current === gen) {
scrollCooldown.current = false;
scheduleSessionTimelineRangeUpdate();
scheduleScrollOverflowReport();
}
}, 150);
const key = getItemKey(rowIndex);
@ -2115,6 +2155,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
getItemKey,
setShouldFollow,
scheduleSessionTimelineRangeUpdate,
scheduleScrollOverflowReport,
],
);
@ -2221,21 +2262,27 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
const curr = el.scrollTop;
lastScrollTop.current = curr;
const distanceFromBottom = el.scrollHeight - curr - el.clientHeight;
scheduleScrollOverflowReport();
// Rule 2: scrolling up → pause follow
if (curr < prev - 1) {
// Container resizes can clamp scrollTop downward while the viewport is
// still at the tail. Treat that as follow mode, not a manual scroll-up.
setShouldFollow(distanceFromBottom < 30);
setShouldFollow(distanceFromBottom < FOLLOW_BOTTOM_THRESHOLD_PX);
return;
}
// Rule 3: near bottom → resume follow
// Run only after non-upward scrolls. Otherwise a tiny wheel-up near the
// tail would pause follow and immediately re-enable it in the same event.
if (distanceFromBottom < 30) {
if (distanceFromBottom < FOLLOW_BOTTOM_THRESHOLD_PX) {
setShouldFollow(true);
}
}, [getScrollElement, scheduleSessionTimelineRangeUpdate, setShouldFollow]);
}, [
getScrollElement,
scheduleScrollOverflowReport,
scheduleSessionTimelineRangeUpdate,
setShouldFollow,
]);
useEffect(() => {
const el = getScrollElement();
@ -2244,6 +2291,33 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
return () => el.removeEventListener('scroll', handleScroll);
}, [getScrollElement, handleScroll]);
useEffect(() => {
const el = getScrollElement();
if (!el || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(scheduleScrollOverflowReport);
observer.observe(el);
for (const child of Array.from(el.children)) {
observer.observe(child);
}
const mutationObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of Array.from(mutation.addedNodes)) {
if (node instanceof HTMLElement) observer.observe(node);
}
for (const node of Array.from(mutation.removedNodes)) {
if (node instanceof HTMLElement) observer.unobserve(node);
}
}
scheduleScrollOverflowReport();
});
mutationObserver.observe(el, { childList: true });
scheduleScrollOverflowReport();
return () => {
observer.disconnect();
mutationObserver.disconnect();
};
}, [getScrollElement, scheduleScrollOverflowReport]);
// Clear screen (e.g. /clear) → reset to follow mode, drop stale per-turn
// collapse overrides, and disarm any deferred scroll so it can't fire
// against the next session.
@ -2487,8 +2561,16 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
}
}, [totalVirtualSize, messages, totalCount, catchingUp, scrollToBottom]);
useLayoutEffect(() => {
scheduleScrollOverflowReport();
}, [messages, scheduleScrollOverflowReport, totalCount, totalVirtualSize]);
return (
<div ref={containerRef} className={styles.list}>
<div
ref={containerRef}
className={styles.list}
onClickCapture={handleDisclosureClickCapture}
>
<SessionTimeline
entries={sessionTimelineEntries}
currentTurnId={currentTimelineTurnId}