fix(desktop): page earlier transcript on top-edge scroll

Long conversations and branched sessions hide older turns behind the
render budget, but only the Show earlier button loaded them. Auto-page
through the same showEarlier path when the reader is at the viewport top.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
KoNit-K
2026-09-09 15:54:40 +08:00
committed by Teknium
parent 15f3037647
commit 3ec8042483
3 changed files with 161 additions and 1 deletions
@@ -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<typeof ThreadPrimitive.MessageByIndex>['components']
@@ -956,6 +956,48 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
}
}, [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
@@ -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)
})
})
@@ -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
}