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:
Teknium
2026-09-09 05:10:56 -07:00
parent 3ec8042483
commit 474143da81
4 changed files with 152 additions and 79 deletions
@@ -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
}