fix(desktop): trim the auto Show-earlier gate and prove it end to end
Drop the injectable `topEdgePx` knob (one caller, one constant) so the threshold has a single home, and fold the two-branch tail of shouldAutoShowEarlier into one predicate. Comments keep the WHY: wheel is the only signal at a clamped scrollTop 0, and the gates name the states where scrollTop is near 0 without meaning "read earlier". Tests: collapse the 11-case matrix into one invariant test on the pure gate, and add a rendered-list test that mounts the real Thread against a transcript heavier than RENDER_BUDGET, escapes the bottom lock, wheels up at the clamped top and asserts more turn groups mount — while an upward wheel mid-transcript mounts none. Red on origin/main (28 groups stay 28), green with the fix.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { stubThreadEnvironment, stubThreadViewportSize } from '../test-utils'
|
||||
|
||||
import { Thread } from '.'
|
||||
|
||||
stubThreadEnvironment()
|
||||
stubThreadViewportSize()
|
||||
|
||||
const SCROLL_H = 20000
|
||||
const CLIENT_H = 600
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => SCROLL_H
|
||||
})
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => CLIENT_H
|
||||
})
|
||||
|
||||
beforeEach(() => window.localStorage.clear())
|
||||
|
||||
async function settle(ticks = 20) {
|
||||
await act(async () => {
|
||||
for (let tick = 0; tick < ticks; tick += 1) {
|
||||
await new Promise<void>(resolve => window.setTimeout(resolve, 0))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Heavy enough that the transcript outgrows RENDER_BUDGET (600 units) and the
|
||||
// list hides older turns behind "Show earlier" — the shape of a branched
|
||||
// session that inherits a long parent history.
|
||||
const heavyText = 'x'.repeat(5000)
|
||||
const createdAt = new Date('2026-08-01T00:00:00.000Z')
|
||||
|
||||
function heavyTranscript(turns: number): ThreadMessage[] {
|
||||
return Array.from({ length: turns }, (_, index) => [
|
||||
{
|
||||
id: `u-${index}`,
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: heavyText }],
|
||||
attachments: [],
|
||||
createdAt,
|
||||
metadata: { custom: {} }
|
||||
} as ThreadMessage,
|
||||
{
|
||||
id: `a-${index}`,
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: heavyText }],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} }
|
||||
} as ThreadMessage
|
||||
]).flat()
|
||||
}
|
||||
|
||||
function Harness({ messages }: { messages: ThreadMessage[] }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({ isRunning: false, messages, onNew: async () => {} })
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread sessionKey="long" />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const mountedGroups = (container: HTMLElement) => container.querySelectorAll('[data-slot="aui_message-group"]').length
|
||||
|
||||
async function settledGroupCount(container: HTMLElement): Promise<number> {
|
||||
let count = mountedGroups(container)
|
||||
|
||||
for (let round = 0; round < 20; round += 1) {
|
||||
await settle(10)
|
||||
const next = mountedGroups(container)
|
||||
|
||||
if (next === count) {
|
||||
return count
|
||||
}
|
||||
|
||||
count = next
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
describe('top-edge auto Show earlier', () => {
|
||||
it('pages older turns in when the reader wheels up at the clamped top, not mid-transcript', async () => {
|
||||
const turns = 60
|
||||
const { container } = render(<Harness messages={heavyTranscript(turns)} />)
|
||||
const viewport = container.querySelector('[data-slot="aui_thread-viewport"]') as HTMLElement
|
||||
|
||||
// Let the stepped first-paint → full-page backfill finish; from here only
|
||||
// Show earlier grows the DOM.
|
||||
const windowed = await settledGroupCount(container)
|
||||
|
||||
expect(windowed).toBeGreaterThan(0)
|
||||
expect(windowed).toBeLessThan(turns)
|
||||
|
||||
// Leave the bottom lock the way a reader does: a scroll-up event.
|
||||
viewport.scrollTop = SCROLL_H - CLIENT_H
|
||||
act(() => viewport.dispatchEvent(new Event('scroll')))
|
||||
viewport.scrollTop = 2000
|
||||
act(() => viewport.dispatchEvent(new Event('scroll')))
|
||||
await settle(5)
|
||||
|
||||
act(() => viewport.dispatchEvent(new WheelEvent('wheel', { deltaY: -120 })))
|
||||
await settle(5)
|
||||
|
||||
expect(mountedGroups(container)).toBe(windowed)
|
||||
|
||||
// At the clamped top the browser emits no further scroll events — only
|
||||
// the wheel says "I want to read earlier".
|
||||
viewport.scrollTop = 0
|
||||
act(() => viewport.dispatchEvent(new Event('scroll')))
|
||||
await settle(5)
|
||||
act(() => viewport.dispatchEvent(new WheelEvent('wheel', { deltaY: -120 })))
|
||||
await settle()
|
||||
|
||||
expect(mountedGroups(container)).toBeGreaterThan(windowed)
|
||||
})
|
||||
})
|
||||
@@ -40,7 +40,7 @@ import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { MessageRenderBoundary } from '../message-render-boundary'
|
||||
|
||||
import { resolveShowEarlierAction, shouldAutoShowEarlier, TOP_EDGE_PX, useTranscriptWindow } from './transcript-window'
|
||||
import { resolveShowEarlierAction, shouldAutoShowEarlier, useTranscriptWindow } from './transcript-window'
|
||||
import { useMessagesBelow } from './use-messages-below'
|
||||
|
||||
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
|
||||
@@ -957,8 +957,8 @@ 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.
|
||||
// path as the button. Wheel is required because browsers emit no `scroll`
|
||||
// once scrollTop is already 0 — exactly where the reader who wants more is.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
@@ -968,26 +968,21 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
|
||||
const tryShowEarlier = (wheelDeltaY?: number) => {
|
||||
if (
|
||||
!shouldAutoShowEarlier({
|
||||
shouldAutoShowEarlier({
|
||||
action: resolveShowEarlierAction(hiddenCount, olderAvailable),
|
||||
isAtBottom,
|
||||
loadSettled: loadSettledRef.current,
|
||||
restorePending: restoreFromBottomRef.current != null,
|
||||
scrollTop: el.scrollTop,
|
||||
topEdgePx: TOP_EDGE_PX,
|
||||
wheelDeltaY
|
||||
})
|
||||
) {
|
||||
return
|
||||
showEarlier()
|
||||
}
|
||||
|
||||
showEarlier()
|
||||
}
|
||||
|
||||
const onScroll = () => tryShowEarlier()
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
tryShowEarlier(event.deltaY)
|
||||
}
|
||||
const onWheel = (event: WheelEvent) => tryShowEarlier(event.deltaY)
|
||||
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
el.addEventListener('wheel', onWheel, { passive: true })
|
||||
|
||||
@@ -7,67 +7,24 @@ const settledTop = {
|
||||
isAtBottom: false,
|
||||
loadSettled: true,
|
||||
restorePending: false,
|
||||
scrollTop: 0,
|
||||
topEdgePx: TOP_EDGE_PX
|
||||
scrollTop: 0
|
||||
}
|
||||
|
||||
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', () => {
|
||||
it('pages only for a settled reader at the top edge with older content to show', () => {
|
||||
// Reading intent: a scroll into the top edge, or an upward wheel while
|
||||
// already clamped there (browsers emit no scroll event at scrollTop 0).
|
||||
expect(shouldAutoShowEarlier(settledTop)).toBe(true)
|
||||
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)
|
||||
})
|
||||
expect(shouldAutoShowEarlier({ ...settledTop, action: resolveShowEarlierAction(0, true) })).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', () => {
|
||||
// Every gate that must keep a page from loading on its own.
|
||||
expect(shouldAutoShowEarlier({ ...settledTop, action: resolveShowEarlierAction(0, false) })).toBe(false)
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -33,7 +33,11 @@ export function resolveShowEarlierAction(hiddenCount: number, olderAvailable: bo
|
||||
return olderAvailable ? 'window' : null
|
||||
}
|
||||
|
||||
/** Slack (px) treated as "already at the top edge" for auto Show-earlier. */
|
||||
/**
|
||||
* Slack (px) within which a reader counts as "at the top edge". Wide enough
|
||||
* that a wheel notch landing a few pixels short of 0 still pages; well under
|
||||
* the RUN_START_SNAP-style thresholds so a mid-transcript reader never does.
|
||||
*/
|
||||
export const TOP_EDGE_PX = 48
|
||||
|
||||
export interface ShouldAutoShowEarlierInput {
|
||||
@@ -42,16 +46,16 @@ export interface ShouldAutoShowEarlierInput {
|
||||
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.
|
||||
* Whether reading at the viewport top should page older turns through the
|
||||
* same `showEarlier()` path as the button. An unsettled load, a prepend
|
||||
* restore still pending, a reader following the bottom, or a mid-transcript
|
||||
* scroll must never page on its own — each of those has scrollTop near 0 or
|
||||
* changing for reasons that are not "I want to read earlier".
|
||||
*/
|
||||
export function shouldAutoShowEarlier({
|
||||
action,
|
||||
@@ -59,20 +63,12 @@ export function shouldAutoShowEarlier({
|
||||
loadSettled,
|
||||
restorePending,
|
||||
scrollTop,
|
||||
topEdgePx = TOP_EDGE_PX,
|
||||
wheelDeltaY
|
||||
}: ShouldAutoShowEarlierInput): boolean {
|
||||
if (action == null || !loadSettled || restorePending || isAtBottom) {
|
||||
if (action == null || !loadSettled || restorePending || isAtBottom || scrollTop > TOP_EDGE_PX) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (scrollTop > topEdgePx) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (wheelDeltaY !== undefined && !(wheelDeltaY < 0)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
// A wheel at the clamped top is intent only when it points up.
|
||||
return wheelDeltaY === undefined || wheelDeltaY < 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user