mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-19 13:44:48 +00:00
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:
parent
55e425c1d2
commit
bf8bdd9e76
3 changed files with 397 additions and 34 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue