diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index 7aa58a339a..b24e6b9995 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -40,7 +40,7 @@ import { isSecondaryWindow } from '@/store/windows' import { MessageRenderBoundary } from '../message-render-boundary' -import { resolveShowEarlierAction, useTranscriptWindow } from './transcript-window' +import { resolveShowEarlierAction, shouldAutoShowEarlier, TOP_EDGE_PX, useTranscriptWindow } from './transcript-window' import { useMessagesBelow } from './use-messages-below' type ThreadMessageComponents = ComponentProps['components'] @@ -956,6 +956,48 @@ const ThreadMessageListInner: FC = ({ } }, [anchorBeforePrepend, expandWindow, hiddenCount, olderAvailable, paneBudget]) + // Scroll/wheel at the top edge pages older turns through the same showEarlier + // path as the button. Wheel is required because browsers often omit `scroll` + // once scrollTop is already 0. Fail-open gates live in shouldAutoShowEarlier. + useEffect(() => { + const el = scrollRef.current + + if (!el) { + return + } + + const tryShowEarlier = (wheelDeltaY?: number) => { + if ( + !shouldAutoShowEarlier({ + action: resolveShowEarlierAction(hiddenCount, olderAvailable), + isAtBottom, + loadSettled: loadSettledRef.current, + restorePending: restoreFromBottomRef.current != null, + scrollTop: el.scrollTop, + topEdgePx: TOP_EDGE_PX, + wheelDeltaY + }) + ) { + return + } + + showEarlier() + } + + const onScroll = () => tryShowEarlier() + const onWheel = (event: WheelEvent) => { + tryShowEarlier(event.deltaY) + } + + el.addEventListener('scroll', onScroll, { passive: true }) + el.addEventListener('wheel', onWheel, { passive: true }) + + return () => { + el.removeEventListener('scroll', onScroll) + el.removeEventListener('wheel', onWheel) + } + }, [hiddenCount, isAtBottom, olderAvailable, scrollRef, showEarlier]) + useLayoutEffect(() => { const el = scrollRef.current const restoreFromBottom = restoreFromBottomRef.current diff --git a/apps/desktop/src/components/assistant-ui/thread/should-auto-show-earlier.test.ts b/apps/desktop/src/components/assistant-ui/thread/should-auto-show-earlier.test.ts new file mode 100644 index 0000000000..e3cad94021 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/should-auto-show-earlier.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' + +import { resolveShowEarlierAction, shouldAutoShowEarlier, TOP_EDGE_PX } from './transcript-window' + +const settledTop = { + action: 'dom' as const, + isAtBottom: false, + loadSettled: true, + restorePending: false, + scrollTop: 0, + topEdgePx: TOP_EDGE_PX +} + +describe('TOP_EDGE_PX', () => { + it('is a small top-edge slack, not a mid-scroll threshold', () => { + expect(TOP_EDGE_PX).toBeGreaterThanOrEqual(48) + expect(TOP_EDGE_PX).toBeLessThanOrEqual(64) + }) +}) + +describe('shouldAutoShowEarlier', () => { + it('loads earlier when the reader is at the top edge with a DOM page to spend', () => { + expect(shouldAutoShowEarlier({ ...settledTop, action: 'dom' })).toBe(true) + }) + + it('loads earlier when the reader is at the top edge with a store window to expand', () => { + expect( + shouldAutoShowEarlier({ + ...settledTop, + action: resolveShowEarlierAction(0, true) + }) + ).toBe(true) + }) + + it('treats scrollTop at the edge pixel as top-edge reading intent', () => { + expect(shouldAutoShowEarlier({ ...settledTop, scrollTop: TOP_EDGE_PX })).toBe(true) + }) + + it('loads earlier on an upward wheel while already at the top edge', () => { + expect(shouldAutoShowEarlier({ ...settledTop, wheelDeltaY: -40 })).toBe(true) + }) + + it('does not fire when resolveShowEarlierAction is a no-op', () => { + expect(shouldAutoShowEarlier({ ...settledTop, action: null })).toBe(false) + expect( + shouldAutoShowEarlier({ + ...settledTop, + action: resolveShowEarlierAction(0, false) + }) + ).toBe(false) + }) + + it('does not fire until the session load has settled', () => { + expect(shouldAutoShowEarlier({ ...settledTop, loadSettled: false })).toBe(false) + }) + + it('does not fire while a prepend restore is still pending', () => { + expect(shouldAutoShowEarlier({ ...settledTop, restorePending: true })).toBe(false) + }) + + it('does not fire while the reader is following the bottom', () => { + expect(shouldAutoShowEarlier({ ...settledTop, isAtBottom: true })).toBe(false) + }) + + it('does not fire in the middle of the transcript', () => { + expect(shouldAutoShowEarlier({ ...settledTop, scrollTop: TOP_EDGE_PX + 1 })).toBe(false) + expect(shouldAutoShowEarlier({ ...settledTop, scrollTop: 400 })).toBe(false) + }) + + it('does not treat a downward or zero wheel at the top as earlier-reading intent', () => { + expect(shouldAutoShowEarlier({ ...settledTop, wheelDeltaY: 40 })).toBe(false) + expect(shouldAutoShowEarlier({ ...settledTop, wheelDeltaY: 0 })).toBe(false) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/transcript-window.tsx b/apps/desktop/src/components/assistant-ui/thread/transcript-window.tsx index 1ce059acd0..b6e6676860 100644 --- a/apps/desktop/src/components/assistant-ui/thread/transcript-window.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/transcript-window.tsx @@ -32,3 +32,47 @@ export function resolveShowEarlierAction(hiddenCount: number, olderAvailable: bo return olderAvailable ? 'window' : null } + +/** Slack (px) treated as "already at the top edge" for auto Show-earlier. */ +export const TOP_EDGE_PX = 48 + +export interface ShouldAutoShowEarlierInput { + action: 'dom' | 'window' | null + isAtBottom: boolean + loadSettled: boolean + restorePending: boolean + scrollTop: number + topEdgePx?: number + /** Present only for `wheel` events; omitted for `scroll`. */ + wheelDeltaY?: number +} + +/** + * Auto Show-earlier is the button's `showEarlier()` path, triggered when the + * reader is at the viewport top and older content exists. Fail-open: missing + * action, an unsettled load, a pending prepend restore, following the bottom, + * or a mid-transcript scroll must not page. + */ +export function shouldAutoShowEarlier({ + action, + isAtBottom, + loadSettled, + restorePending, + scrollTop, + topEdgePx = TOP_EDGE_PX, + wheelDeltaY +}: ShouldAutoShowEarlierInput): boolean { + if (action == null || !loadSettled || restorePending || isAtBottom) { + return false + } + + if (scrollTop > topEdgePx) { + return false + } + + if (wheelDeltaY !== undefined && !(wheelDeltaY < 0)) { + return false + } + + return true +}