diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 38cc1eb125..4aa6018320 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -1040,12 +1040,12 @@ export function App({ const editorRef = useRef(null); const notifiedComposerReadyRef = useRef(null); const footerRef = useRef(null); - const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [canScrollMessageListToBottom, setCanScrollMessageListToBottom] = + useState(false); const previousFooterRectRef = useRef(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({
- {showScrollToBottom && ( + {canScrollMessageListToBottom && (
{ 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, - 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} /> , ); @@ -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(); + 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((resolve) => setTimeout(resolve, 230))); + await nextFrame(); + + expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(true); + }); }); diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index f3904ba54a..6dd4ca97dd 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -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( showRetryHint = false, onRetryClick, onBranchSession, - onFollowStateChange, + onCanScrollToBottomChange, }, ref, ) { @@ -1682,7 +1684,7 @@ export const MessageList = forwardRef( const scrollCooldown = useRef(false); const scrollCooldownCount = useRef(0); const sessionTimelineFrame = useRef(null); - const lastReportedFollow = useRef(true); + const lastReportedCanScrollToBottom = useRef(null); const prevLastUserMsgId = useRef(null); const prevActiveExecutionKey = useRef(null); const prevCatchingUp: MutableRefObject = @@ -1692,17 +1694,37 @@ export const MessageList = forwardRef( const pendingFollowRecheck = useRef(false); const pendingFollowRecheckFrame = useRef(undefined); const pendingFollowRecheckTimer = useRef(undefined); + const pendingOverflowFrame = useRef(undefined); catchingUpRef.current = catchingUp; const containerRef = useRef(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( // 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( 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( if (pendingFollowRecheckTimer.current !== undefined) { window.clearTimeout(pendingFollowRecheckTimer.current); } + if (pendingOverflowFrame.current !== undefined) { + window.cancelAnimationFrame(pendingOverflowFrame.current); + } }, [], ); @@ -1855,14 +1882,13 @@ export const MessageList = forwardRef( // 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( [scheduleFollowRecheck, setShouldFollow], ); + const handleDisclosureClickCapture = useCallback( + (event: ReactMouseEvent) => { + 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( } 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( requestAnimationFrame(releaseCooldown); } }, - [getScrollElement], + [getScrollElement, reportCanScrollToBottom, scheduleScrollOverflowReport], ); const resumeBottomFollow = useCallback( @@ -2103,6 +2142,7 @@ export const MessageList = forwardRef( if (scrollCooldownCount.current === gen) { scrollCooldown.current = false; scheduleSessionTimelineRangeUpdate(); + scheduleScrollOverflowReport(); } }, 150); const key = getItemKey(rowIndex); @@ -2115,6 +2155,7 @@ export const MessageList = forwardRef( getItemKey, setShouldFollow, scheduleSessionTimelineRangeUpdate, + scheduleScrollOverflowReport, ], ); @@ -2221,21 +2262,27 @@ export const MessageList = forwardRef( 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( 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( } }, [totalVirtualSize, messages, totalCount, catchingUp, scrollToBottom]); + useLayoutEffect(() => { + scheduleScrollOverflowReport(); + }, [messages, scheduleScrollOverflowReport, totalCount, totalVirtualSize]); + return ( -
+