Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 0 additions & 111 deletions mobile/src/session/MobileNativeChatView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,117 +383,6 @@ describe('MobileNativeChatView', () => {
}
})

it('uses momentum-end metrics instead of a stale throttled scroll sample', async () => {
const folded = [assistantTurn('a1', 'Latest')]
await render({ folded })
scrollToEnd.mockClear()

act(() => {
list().props.onScrollBeginDrag?.({})
list().props.onScroll({
nativeEvent: {
contentOffset: { y: 700 },
contentSize: { height: 1_200 },
layoutMeasurement: { height: 500 }
}
})
list().props.onScrollEndDrag?.({
nativeEvent: {
contentOffset: { y: 700 },
contentSize: { height: 1_200 },
layoutMeasurement: { height: 500 }
}
})
list().props.onMomentumScrollBegin?.({})
list().props.onMomentumScrollEnd?.({
nativeEvent: {
contentOffset: { y: 200 },
contentSize: { height: 1_300 },
layoutMeasurement: { height: 500 }
}
})
list().props.onContentSizeChange(320, 1_350)
})

expect(scrollToEnd).not.toHaveBeenCalled()
expect(scrollToOffset).not.toHaveBeenCalled()
expect(
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
).toHaveLength(1)
})

it('uses finger-release metrics when no momentum event follows', async () => {
vi.useFakeTimers()
try {
const folded = [assistantTurn('a1', 'Latest')]
await render({ folded })
scrollToEnd.mockClear()

act(() => {
list().props.onScrollBeginDrag?.({})
list().props.onScroll({
nativeEvent: {
contentOffset: { y: 200 },
contentSize: { height: 1_200 },
layoutMeasurement: { height: 500 }
}
})
list().props.onScrollEndDrag?.({
nativeEvent: {
contentOffset: { y: 620 },
contentSize: { height: 1_200 },
layoutMeasurement: { height: 500 }
}
})
})
await act(async () => {
vi.advanceTimersByTime(200)
})
act(() => list().props.onContentSizeChange(320, 1_250))

expect(scrollToEnd).toHaveBeenCalledOnce()
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_250 })
} finally {
vi.useRealTimers()
}
})

it('repins when content grows between tail release and drag settle', async () => {
vi.useFakeTimers()
try {
const folded = [assistantTurn('a1', 'Latest')]
await render({ folded })
scrollToEnd.mockClear()

act(() => {
list().props.onScrollBeginDrag?.({})
list().props.onScrollEndDrag?.({
nativeEvent: {
contentOffset: { y: 700 },
contentSize: { height: 1_200 },
layoutMeasurement: { height: 500 }
}
})
list().props.onContentSizeChange(320, 1_250)
})

expect(scrollToEnd).not.toHaveBeenCalled()
expect(scrollToOffset).not.toHaveBeenCalled()

await act(async () => {
vi.runOnlyPendingTimers()
})

expect(scrollToEnd).toHaveBeenCalledOnce()
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
expect(
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
).toHaveLength(0)
} finally {
vi.useRealTimers()
}
})

it('uses measured content height when the first message fills an empty transcript', async () => {
await render()

Expand Down
159 changes: 159 additions & 0 deletions mobile/src/session/use-mobile-native-chat-tail-follow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
useMobileNativeChatTailFollow,
type MobileNativeChatTailFollow
} from './use-mobile-native-chat-tail-follow'

const scrollToEnd = vi.fn()
const scrollToOffset = vi.fn()

let renderer: ReactTestRenderer | null = null
let tail: MobileNativeChatTailFollow<string> | null = null

// Wired the way MobileNativeChatView wires its transcript list.
function Transcript(): ReturnType<typeof createElement> {
const follow = useMobileNativeChatTailFollow<string>({ hasItems: true })
tail = follow
return createElement('FlatList', {
ref: follow.listRef,
onScroll: (event: { nativeEvent: Parameters<typeof follow.recordScrollMetrics>[0] }) =>
follow.recordScrollMetrics(event.nativeEvent),
onScrollBeginDrag: follow.beginUserScroll,
onScrollEndDrag: follow.endUserDrag,
onMomentumScrollBegin: follow.beginMomentum,
onMomentumScrollEnd: follow.endMomentum,
onContentSizeChange: follow.pinToTailAfterContentResize
})
}

// Host element names are plain strings at runtime; React Native's types don't list them.
function hostNamed(name: string): ReactTestInstance {
return renderer!.root.find((node) => node.type === name)
}

function list(): ReactTestInstance {
return hostNamed('FlatList')
}

/** A scroll event in a 500pt viewport; 80pt from the bottom still counts as the tail. */
function at(offsetY: number, contentHeight = 1_200) {
return {
nativeEvent: {
contentOffset: { y: offsetY },
contentSize: { height: contentHeight },
layoutMeasurement: { height: 500 }
}
}
}

describe('useMobileNativeChatTailFollow gesture settling', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
setTimeout(() => callback(0), 0)
)
vi.stubGlobal('cancelAnimationFrame', (handle: ReturnType<typeof setTimeout>) =>
clearTimeout(handle)
)
act(() => {
renderer = create(createElement(Transcript), {
createNodeMock: () => ({ scrollToEnd, scrollToOffset })
})
})
})

afterEach(() => {
act(() => renderer?.unmount())
renderer = null
tail = null
scrollToEnd.mockReset()
scrollToOffset.mockReset()
vi.unstubAllGlobals()
vi.useRealTimers()
})

// iOS emits a bare momentum-end after a non-animated pin; Android a begin/end pair after an animated one.
it.each([
['momentum-end', false],
['momentum-begin/end pair', true]
])('ignores a programmatic %s while the finger is still down', (_label, withBegin) => {
act(() => {
list().props.onScrollBeginDrag({})
if (withBegin) {
list().props.onMomentumScrollBegin({})
}
list().props.onMomentumScrollEnd(at(700))
list().props.onContentSizeChange(320, 1_250)
})

expect(scrollToEnd).not.toHaveBeenCalled()
expect(scrollToOffset).not.toHaveBeenCalled()
})

it('settles a released drag from release metrics, not a programmatic momentum-end', () => {
act(() => {
list().props.onScrollBeginDrag({})
list().props.onScrollEndDrag(at(200))
list().props.onMomentumScrollEnd(at(700))
})
act(() => {
vi.runOnlyPendingTimers()
})
act(() => list().props.onContentSizeChange(320, 1_250))

expect(scrollToEnd).not.toHaveBeenCalled()
expect(scrollToOffset).not.toHaveBeenCalled()
expect(tail!.showJumpToTail).toBe(true)
})

it('uses momentum-end metrics instead of a stale throttled scroll sample', () => {
act(() => {
list().props.onScrollBeginDrag({})
list().props.onScroll(at(700))
list().props.onScrollEndDrag(at(700))
list().props.onMomentumScrollBegin({})
list().props.onMomentumScrollEnd(at(200, 1_300))
list().props.onContentSizeChange(320, 1_350)
})

expect(scrollToEnd).not.toHaveBeenCalled()
expect(scrollToOffset).not.toHaveBeenCalled()
expect(tail!.showJumpToTail).toBe(true)
})

it('uses finger-release metrics when no momentum event follows', () => {
act(() => {
list().props.onScrollBeginDrag({})
list().props.onScroll(at(200))
list().props.onScrollEndDrag(at(620))
})
act(() => {
vi.advanceTimersByTime(200)
})
act(() => list().props.onContentSizeChange(320, 1_250))

expect(scrollToEnd).toHaveBeenCalledOnce()
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_250 })
})

it('repins when content grows between tail release and drag settle', () => {
act(() => {
list().props.onScrollBeginDrag({})
list().props.onScrollEndDrag(at(700))
list().props.onContentSizeChange(320, 1_250)
})

expect(scrollToEnd).not.toHaveBeenCalled()
expect(scrollToOffset).not.toHaveBeenCalled()

act(() => {
vi.runOnlyPendingTimers()
})

expect(scrollToEnd).toHaveBeenCalledOnce()
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
expect(tail!.showJumpToTail).toBe(false)
})
})
35 changes: 22 additions & 13 deletions mobile/src/session/use-mobile-native-chat-tail-follow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import type { FlatList, NativeScrollEvent, NativeSyntheticEvent } from 'react-na
/** Distance from the bottom, in points, still treated as "at the tail". */
const AT_TAIL_SLOP = 80

/** A user gesture's progress; each native event is honored only in the phase that expects it. */
type UserScrollPhase = 'idle' | 'dragging' | 'released' | 'momentum'

function isAtTail(event: NativeScrollEvent): boolean {
const { contentOffset, contentSize, layoutMeasurement } = event
return contentSize.height - (contentOffset.y + layoutMeasurement.height) <= AT_TAIL_SLOP
Expand Down Expand Up @@ -53,7 +56,7 @@ export function useMobileNativeChatTailFollow<TItem>(args: {
// Event handlers read intent at event time, before a re-render lands.
const followingRef = useRef(true)
const atTailRef = useRef(true)
const userScrollActiveRef = useRef(false)
const userScrollPhaseRef = useRef<UserScrollPhase>('idle')
const userScrollSettleFrameRef = useRef<number | null>(null)

// Single writer, so the event-time ref and the render flag cannot disagree.
Expand Down Expand Up @@ -104,25 +107,22 @@ export function useMobileNativeChatTailFollow<TItem>(args: {

const jumpToTail = useCallback(() => {
clearUserScrollSettle()
userScrollActiveRef.current = false
userScrollPhaseRef.current = 'idle'
setAtTail(true)
setFollowing(true)
pinToTail()
}, [clearUserScrollSettle, pinToTail, setAtTail, setFollowing])

const beginUserScroll = useCallback(() => {
clearUserScrollSettle()
userScrollActiveRef.current = true
userScrollPhaseRef.current = 'dragging'
setFollowing(false)
}, [clearUserScrollSettle, setFollowing])

const finishUserScroll = useCallback(
(finishedAtTail: boolean) => {
if (!userScrollActiveRef.current) {
return
}
clearUserScrollSettle()
userScrollActiveRef.current = false
userScrollPhaseRef.current = 'idle'
setAtTail(finishedAtTail)
setFollowing(finishedAtTail)
if (finishedAtTail) {
Expand All @@ -134,10 +134,11 @@ export function useMobileNativeChatTailFollow<TItem>(args: {

const endUserDrag = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (!userScrollActiveRef.current) {
if (userScrollPhaseRef.current !== 'dragging') {
return
}
clearUserScrollSettle()
userScrollPhaseRef.current = 'released'
const releasedAtTail = isAtTail(event.nativeEvent)
userScrollSettleFrameRef.current = requestAnimationFrame(() => {
userScrollSettleFrameRef.current = null
Expand All @@ -147,21 +148,29 @@ export function useMobileNativeChatTailFollow<TItem>(args: {
[clearUserScrollSettle, finishUserScroll]
)

// Android also emits this for animated programmatic scrolls, so only a release hands off to it.
const beginMomentum = useCallback(() => {
if (userScrollActiveRef.current) {
clearUserScrollSettle()
if (userScrollPhaseRef.current !== 'released') {
return
}
clearUserScrollSettle()
userScrollPhaseRef.current = 'momentum'
}, [clearUserScrollSettle])

// iOS also emits this, with no begin, after every non-animated pin — even mid-drag.
const endMomentum = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) =>
finishUserScroll(isAtTail(event.nativeEvent)),
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (userScrollPhaseRef.current !== 'momentum') {
return
}
finishUserScroll(isAtTail(event.nativeEvent))
},
[finishUserScroll]
)

const detachFromTail = useCallback(() => {
clearUserScrollSettle()
userScrollActiveRef.current = false
userScrollPhaseRef.current = 'idle'
setAtTail(false)
setFollowing(false)
}, [clearUserScrollSettle, setAtTail, setFollowing])
Expand Down
Loading