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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user