From f2c0692dba98603819f6529f3eb65307410e5f72 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Wed, 11 Nov 2026 21:37:13 +0900 Subject: [PATCH 1/6] fix(mobile): let code blocks and tables scroll sideways inside a reply-swipe bubble The swipe-to-reply pan claimed every horizontal drag on a bubble, so wide code blocks and Markdown tables could not scroll. ReplySwipe now hands its bubble a blocker that wraps each horizontal scroller in a native gesture blocking the pan: a drag that starts on a scroller scrolls it, a left swipe elsewhere still replies. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/grep.exe.stackdump | 30 ++++++++ .../mobile/src/ui/chat/reply-swipe-context.ts | 5 ++ apps/mobile/src/ui/chat/reply-swipe.tsx | 70 ++++++++++++------- apps/mobile/src/ui/markdown-table.tsx | 7 +- apps/mobile/src/ui/markdown-text.tsx | 11 ++- .../src/ui/primitives/horizontal-scroll.tsx | 10 +++ apps/mobile/test/horizontal-scroll.test.tsx | 28 ++++++++ 7 files changed, 126 insertions(+), 35 deletions(-) create mode 100644 apps/mobile/grep.exe.stackdump create mode 100644 apps/mobile/src/ui/chat/reply-swipe-context.ts create mode 100644 apps/mobile/src/ui/primitives/horizontal-scroll.tsx create mode 100644 apps/mobile/test/horizontal-scroll.test.tsx diff --git a/apps/mobile/grep.exe.stackdump b/apps/mobile/grep.exe.stackdump new file mode 100644 index 0000000000..5981550209 --- /dev/null +++ b/apps/mobile/grep.exe.stackdump @@ -0,0 +1,30 @@ +Stack trace: +Frame Function Args +0007FFFFBD30 0002100615B7 (000000000002, 000000000002, 000000000000, 0007FFFFDC10) msys-2.0.dll+0x215B7 +000000000000 0002100636B5 (0007FFFFC6E0, 000000000000, 0000000000B0, 000000000000) msys-2.0.dll+0x236B5 +0007FFFFC440 0002100C3CBB (000000000000, 934CE0E80E43, 000000000000, 000000000000) msys-2.0.dll+0x83CBB +0000000000C1 0002100BF34B (000210229CCD, 7FFC00000000, 000000000000, 000000000000) msys-2.0.dll+0x7F34B +0007FFFFC808 0002100BF755 (0002101138DB, 0007FFFFCAC0, 0000000000B5, 0000000000B5) msys-2.0.dll+0x7F755 +0007FFFFC808 00021021A7D8 (0007FFFFC88D, 000000000003, 0007FFFFC898, 000000000000) msys-2.0.dll+0x1DA7D8 +0007FFFFC808 00010042A1E5 (0000000001F0, 000000000000, 000000000000, 000A00000628) grep.exe+0x2A1E5 +0007FFFFCB40 000100404B7C (000000000016, 0007FFFFCB40, 00021005BFDF, 000000000000) grep.exe+0x4B7C +0007FFFFCB40 000100429678 (000000000000, 0007FFFFCD30, 00080000AD88, 0007FFFFCDF0) grep.exe+0x29678 +0007FFFFCD30 000210048140 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x8140 +0007FFFFFFF0 000210045C86 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x5C86 +0007FFFFFFF0 000210045D34 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x5D34 +End of stack trace +Loaded modules: +000100400000 grep.exe +7FFC3C570000 ntdll.dll +7FFC3BB40000 KERNEL32.DLL +7FFC397D0000 KERNELBASE.dll +0005603F0000 msys-iconv-2.dll +000430B30000 msys-intl-8.dll +000210040000 msys-2.0.dll +0004C36D0000 msys-pcre-1.dll +7FFC3B770000 advapi32.dll +7FFC3BD10000 msvcrt.dll +7FFC3ABE0000 sechost.dll +7FFC3A3F0000 RPCRT4.dll +7FFC38B70000 CRYPTBASE.DLL +7FFC39E80000 bcryptPrimitives.dll diff --git a/apps/mobile/src/ui/chat/reply-swipe-context.ts b/apps/mobile/src/ui/chat/reply-swipe-context.ts new file mode 100644 index 0000000000..2ff438cbf2 --- /dev/null +++ b/apps/mobile/src/ui/chat/reply-swipe-context.ts @@ -0,0 +1,5 @@ +import { createContext, type ComponentType, type ReactNode } from 'react'; + +export type SwipeBlocker = ComponentType<{ children: ReactNode }>; + +export const ReplySwipeBlocker = createContext(undefined); diff --git a/apps/mobile/src/ui/chat/reply-swipe.tsx b/apps/mobile/src/ui/chat/reply-swipe.tsx index 243d911af6..b55c9943a4 100644 --- a/apps/mobile/src/ui/chat/reply-swipe.tsx +++ b/apps/mobile/src/ui/chat/reply-swipe.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import { useCallback, useMemo, useRef, type ReactNode } from 'react'; import { I18nManager, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { @@ -12,6 +12,7 @@ import { spring } from '../design/motion'; import { space, swipeReply } from '../design/tokens'; import { haptic } from '../haptics'; import { ReplyIcon } from '../shell/icons'; +import { ReplySwipeBlocker, type SwipeBlocker } from './reply-swipe-context'; import { horizontalSwipeOwner, repliesAt, replyTravel } from './reply-swipe-model'; export function ReplySwipe({ @@ -27,29 +28,44 @@ export function ReplySwipe({ const travel = useSharedValue(0); const armed = useSharedValue(false); const direction = I18nManager.isRTL ? 1 : -1; - const arm = () => haptic('selection'); - const pan = Gesture.Pan() - .enabled(enabled) - .activeOffsetX(direction * swipeReply.start) - .failOffsetX(-direction * swipeReply.backFail) - .failOffsetY([-swipeReply.verticalFail, swipeReply.verticalFail]) - .onStart(() => { - armed.value = false; - }) - .onUpdate((event) => { - const x = event.translationX * direction; - travel.value = replyTravel(x); - if (armed.value === repliesAt(x)) return; - armed.value = repliesAt(x); - if (armed.value) runOnJS(arm)(); - }) - .onEnd((event) => { - if (repliesAt(event.translationX * direction)) runOnJS(onReply)(); - }) - .onFinalize(() => { - armed.value = false; - travel.value = withSpring(0, spring.spatial.fast); - }); + const reply = useRef(onReply); + reply.current = onReply; + const arm = useCallback(() => haptic('selection'), []); + const fire = useCallback(() => reply.current(), []); + const pan = useMemo( + () => + Gesture.Pan() + .enabled(enabled) + .activeOffsetX(direction * swipeReply.start) + .failOffsetX(-direction * swipeReply.backFail) + .failOffsetY([-swipeReply.verticalFail, swipeReply.verticalFail]) + .onStart(() => { + armed.value = false; + }) + .onUpdate((event) => { + const x = event.translationX * direction; + travel.value = replyTravel(x); + if (armed.value === repliesAt(x)) return; + armed.value = repliesAt(x); + if (armed.value) runOnJS(arm)(); + }) + .onEnd((event) => { + if (repliesAt(event.translationX * direction)) runOnJS(fire)(); + }) + .onFinalize(() => { + armed.value = false; + travel.value = withSpring(0, spring.spatial.fast); + }), + [enabled, direction, arm, fire, armed, travel], + ); + const blocker = useMemo( + () => + function SwipeBlocker({ children: scroller }: { children: ReactNode }) { + const native = useMemo(() => Gesture.Native().blocksExternalGesture(pan), []); + return {scroller}; + }, + [pan], + ); const moving = useAnimatedStyle(() => ({ transform: [{ translateX: travel.value * direction }], })); @@ -86,7 +102,11 @@ export function ReplySwipe({ - {children} + + + {children} + + ); diff --git a/apps/mobile/src/ui/markdown-table.tsx b/apps/mobile/src/ui/markdown-table.tsx index 66e6a805a8..fd92ba6ef3 100644 --- a/apps/mobile/src/ui/markdown-table.tsx +++ b/apps/mobile/src/ui/markdown-table.tsx @@ -1,7 +1,8 @@ import type { Tokens } from 'marked'; import { memo, useMemo, type ReactNode } from 'react'; import type { Token } from 'marked'; -import { ScrollView, View } from 'react-native'; +import { View } from 'react-native'; +import { HorizontalScroll } from './primitives/horizontal-scroll'; import { edge, markdownTable, radius, space } from './design/tokens'; import { useDesignTheme } from './design/theme'; @@ -107,7 +108,7 @@ export function MarkdownTable({ ); const align = JSON.stringify(table.align); return ( - + ))} - + ); } diff --git a/apps/mobile/src/ui/markdown-text.tsx b/apps/mobile/src/ui/markdown-text.tsx index 5102810d4c..9d998eee4b 100644 --- a/apps/mobile/src/ui/markdown-text.tsx +++ b/apps/mobile/src/ui/markdown-text.tsx @@ -1,7 +1,8 @@ import { decodeHTML } from 'entities'; import type { Token } from 'marked'; import type { ReactNode } from 'react'; -import { Pressable, ScrollView, View } from 'react-native'; +import { Pressable, View } from 'react-native'; +import { HorizontalScroll } from './primitives/horizontal-scroll'; import { copyMessage } from './chat/message-menu'; import { edge, radius, space } from './design/tokens'; @@ -102,15 +103,11 @@ export function MarkdownText({ - + {token.text} - + ); if (token.type === 'list') diff --git a/apps/mobile/src/ui/primitives/horizontal-scroll.tsx b/apps/mobile/src/ui/primitives/horizontal-scroll.tsx new file mode 100644 index 0000000000..d899aa5f38 --- /dev/null +++ b/apps/mobile/src/ui/primitives/horizontal-scroll.tsx @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { ScrollView, type ScrollViewProps } from 'react-native'; + +import { ReplySwipeBlocker } from '../chat/reply-swipe-context'; + +export function HorizontalScroll(props: ScrollViewProps) { + const Blocker = useContext(ReplySwipeBlocker); + const view = ; + return Blocker === undefined ? view : {view}; +} diff --git a/apps/mobile/test/horizontal-scroll.test.tsx b/apps/mobile/test/horizontal-scroll.test.tsx new file mode 100644 index 0000000000..ecbcbb465e --- /dev/null +++ b/apps/mobile/test/horizontal-scroll.test.tsx @@ -0,0 +1,28 @@ +import { createElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { expect, it, vi } from 'vitest'; + +vi.mock('react-native', async () => import('./react-native-shim')); + +const { HorizontalScroll } = await import('../src/ui/primitives/horizontal-scroll'); +const { ReplySwipeBlocker } = await import('../src/ui/chat/reply-swipe-context'); + +it('lets a scroller inside a reply-swipe bubble claim its own horizontal drags', () => { + const Blocker = ({ children }: { children: ReactNode }) => + createElement('section', { 'data-blocker': 'reply' }, children); + const html = renderToStaticMarkup( + createElement( + ReplySwipeBlocker.Provider, + { value: Blocker }, + createElement(HorizontalScroll, null, 'wide table'), + ), + ); + expect(html).toContain('data-blocker="reply"'); + expect(html).toContain('wide table'); +}); + +it('is a plain horizontal scroller outside a bubble', () => { + const html = renderToStaticMarkup(createElement(HorizontalScroll, null, 'code')); + expect(html).not.toContain('data-blocker'); + expect(html).toContain('code'); +}); From 3f6432b9c2e34d2d734c0725beb8586d07159538 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Wed, 11 Nov 2026 21:43:55 +0900 Subject: [PATCH 2/6] fix(mobile): finish following the newest message when the native glide stops short With the keyboard open, a send could leave the new message half behind the composer: the native LatestAnchor glide stops for good when something else moves the scroll mid-glide. After content grows while following, the list now checks once the glide should be done and scrolls the rest of the way, unless the Human is dragging or the keyboard is moving. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/DESIGN.md | 2 +- apps/mobile/src/ui/chat/follow-latest.ts | 19 +- .../test/follow-latest-gestures.test.tsx | 2343 +++++++++-------- 3 files changed, 1202 insertions(+), 1162 deletions(-) diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index fb66297ef8..2a3a72bae6 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -410,7 +410,7 @@ The two-tab shell follows [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two- - iOS: messages run under the transparent header and the status bar, and UIKit's scroll edge effect (left on `automatic`) softens them there. The list uses `contentInsetAdjustmentBehavior="automatic"`, so UIKit, not a manual padding, keeps the header's height free above the oldest message at rest; the edge effect needs that automatic inset. The edge effect and React Native Screens both find the content scroll view through the first-subview chain of the screen, so the message list is the first thing in that chain: a default or colour chat background is the frame's own `backgroundColor`, not a view in front of the list. An image background still needs a view behind the list, and with one the soft edge does not reach the list. UIKit also adds the home-indicator safe area to the bottom inset while the list reaches the screen bottom (keyboard closed); the list cancels it with a negative `contentInset.bottom`, because the composer's height already includes that padding. Without this, scrolling to the very end left 34pt more space below the newest message than the resting position. On iOS 26 there is no painted top gradient. A transparent header dimmed the whole message list on iOS 26 while the list was inverted (a scaleY flip); the list now runs top to bottom, which this header depends on. - The list runs to the bottom of the screen, and the composer floats over it with no bar of its own; messages pass around and under its composer island. On iOS the **+** control and field share one taller rounded surface; on iOS 26 with Reduce Transparency off it is Liquid Glass. Older iOS and Reduce Transparency give it an opaque `surface` fill with a `borderStrong` hairline, and Android gives the field the opaque `raised` fill, so on those nothing shows through where the Human types. On Android the field is a floating island like the header's (Human decision, 2026-11-11, after Telegram Android): the same hairline rim, one-point inner top highlight and soft drop shadow (`chatComposerSurface`), drawn with React Native `boxShadow` so nothing is captured or blurred. The round jump-to-latest button above it is the same island, in place of its Material elevation. The bottom of the screen fades out with the same light gradient over the composer and `edgeFade.bottom` above it. The list keeps that much space below the newest message, so at rest it sits just above the composer; scrolled up, messages pass behind the composer. Dragging the list dismisses the keyboard: interactively on iOS (the keyboard follows the finger once it reaches it), on drag on Android. On iOS and Android the bottom edge is a drawn mask layer between the list and the composer, like Grok's chat: one soft curve (`(1 − d)^edgeFade.curve`, sampled `edgeFade.samples` times) painted in the chat's own background colour (`base` with an image background). On Android the bottom edge is not held solid: it uses the header's curve (`chatComposerSurface.fade`), from `peak` at the screen edge to transparent `edgeFade.bottom` above the capsule, so messages show faintly beside and below the floating composer as in Telegram. On iOS, at the bottom it is held solid from the screen edge up to `space.x2` below the top of the composer's field capsule, whatever the draft's line count, then the curve clears over `edgeFade.bottom` just above the capsule, so messages stay readable right up to the composer and are hidden under it. At the top (Android's floating header, and iOS before 26) it is held solid across the status-bar safe area, then the curve clears `edgeFade.reach` below the header. iOS 26 hides UIKit's bottom scroll edge effect for the chat (`scrollEdgeEffects.bottom: 'hidden'`) and does not use a `UIScrollEdgeElementContainerInteraction` on the composer: UIKit picked the hard style there, an opaque band in a different colour from the list with a hairline across its top, at a height that did not follow a wrapped draft, and neither re-adding the interaction nor pinning the `soft` style fixed it. The top edge stays UIKit's automatic scroll edge effect. With the keyboard open the composer's pill sits `space.x3` above it: the keyboard already covers the home indicator, so the chat frame takes the composer's safe-area padding back out of the keyboard offset instead of counting it twice. - Messages: - - **Swipe left to reply; swipe right is always Back** (Human decision, 2026-11-03, from user feedback, replacing the swipe right to reply: a swipe right is the system Back on iOS, so a bubble must never claim it). A swipe left on a committed Human or Bot message (not a pending or failed one, a card, an activity row, a notice or the welcome) moves its row with the finger, up to `swipeReply.reach` (72pt) and then with resistance, while a round reply glyph fades and grows in behind its trailing edge. Passing `swipeReply.commit` (56pt) gives one `selection` haptic; letting go there starts a reply, and the row springs back either way. The recognizer starts only leftward after `swipeReply.start` (12pt), fails on 10pt of vertical travel and on `swipeReply.backFail` (8pt) of rightward travel, so a swipe right that starts on a bubble is still Back. A replyable row carries the native marker `horizontal-swipe-owner` (`nativeID`), and the containers that turn a swipe left into the profile skip a drag that starts on it: the iOS chat/profile pager (a gate recognizer in our `react-native-pager-view` patch that the page drag requires to fail) and the Android profile side sheet (`EndSheet`, which already skipped horizontally scrollable targets). A swipe left anywhere else in the chat opens the profile. The chat screens enable the iOS full-screen back swipe (`fullScreenGestureEnabled: true`), and the same pager gate leaves a swipe right on the chat page to that back swipe; on the profile page a swipe right returns to the chat. Android Back stays the system gesture, which the chat no longer competes with. RTL mirrors both directions. Starting a reply focuses the composer and shows a reply bar above it (on Android inside the composer island, above the text row): a reply glyph, "Replying to {name}" ("You" for the Human's own message) and one line of the message, with ✕ (Cancel reply) to drop it. Sending carries `replyTo` to the Host's `channelSend`, which refuses a target outside the Channel; the bar clears on send. The swipe is off while selecting messages, without a composer (Bot-to-Bot DMs) and while the Host is not connected. CLI: `deepseekbot send --body … --reply-to `. + - **Swipe left to reply; swipe right is always Back** (Human decision, 2026-11-03, from user feedback, replacing the swipe right to reply: a swipe right is the system Back on iOS, so a bubble must never claim it). A swipe left on a committed Human or Bot message (not a pending or failed one, a card, an activity row, a notice or the welcome) moves its row with the finger, up to `swipeReply.reach` (72pt) and then with resistance, while a round reply glyph fades and grows in behind its trailing edge. Passing `swipeReply.commit` (56pt) gives one `selection` haptic; letting go there starts a reply, and the row springs back either way. A drag that starts on a horizontally scrollable part of the bubble (a code block or a table) scrolls it instead: each such scroller sits in a native gesture that blocks the reply pan (`HorizontalScroll`). The recognizer starts only leftward after `swipeReply.start` (12pt), fails on 10pt of vertical travel and on `swipeReply.backFail` (8pt) of rightward travel, so a swipe right that starts on a bubble is still Back. A replyable row carries the native marker `horizontal-swipe-owner` (`nativeID`), and the containers that turn a swipe left into the profile skip a drag that starts on it: the iOS chat/profile pager (a gate recognizer in our `react-native-pager-view` patch that the page drag requires to fail) and the Android profile side sheet (`EndSheet`, which already skipped horizontally scrollable targets). A swipe left anywhere else in the chat opens the profile. The chat screens enable the iOS full-screen back swipe (`fullScreenGestureEnabled: true`), and the same pager gate leaves a swipe right on the chat page to that back swipe; on the profile page a swipe right returns to the chat. Android Back stays the system gesture, which the chat no longer competes with. RTL mirrors both directions. Starting a reply focuses the composer and shows a reply bar above it (on Android inside the composer island, above the text row): a reply glyph, "Replying to {name}" ("You" for the Human's own message) and one line of the message, with ✕ (Cancel reply) to drop it. Sending carries `replyTo` to the Host's `channelSend`, which refuses a target outside the Channel; the bar clears on send. The swipe is off while selecting messages, without a composer (Bot-to-Bot DMs) and while the Host is not connected. CLI: `deepseekbot send --body … --reply-to `. - A reply shows its quote as a tinted block at the top of its bubble (option B2 of #3857, Telegram-style): a `swipeReply.bar` (3pt) bar down its leading edge and the quoted author's name in `meta` strong in that author's colour, then one line of the original in `meta` (three lines at large text sizes), from the Host's `replyToPreview`. The author colour is the Bot's primary colour (`botColor`) and `accent` for the Human, both put through the shared `botNameColor` contrast check against the bubble; a bridged author uses `fg`. The block's fill is that colour mixed 13% (`replyQuote.tint`) into the bubble's own fill, as an opaque colour, at radius `sm`. On the Human's `accent` bubble the bar, name and fill use `onAccent` instead, so the quote stays readable on any Bot's colour. Tapping the block jumps to the original (the same scroll-and-load path as a notification's linked message); screen readers hear it as one button, "Reply to {name}: {text}", and reach the jump through the bubble's "Shows the original message" action. A reply whose original is gone reads "Original message unavailable" in a `muted` block that is not a button. The pending copy of a reply quotes the loaded message until the Host's entry arrives. When the preview carries a media hint, the line says what was quoted: an image shows a 32pt thumbnail (`replyThumb`, radius `xs`) beside the name and its caption, or "Photo" / "3 photos" without one; voice reads "Voice · 0:07" ("语音 · 0:07"); a file without text reads "File · Q3.pdf". - Activity rows show Bot DMs, posts elsewhere, memory commits, Bot creation, departures and stopped turns as one left-aligned meta line with a kind icon and tail ellipsis. They use the same `bubbleRow(true)` inset and single `space.x4` (12pt) top gap as a new message group, with no bottom padding or vertically centred minimum-height box: bubble → activity, activity → activity and activity → new message group keep the same boundary gap. Icons and avatars match the meta line height. In groups, the actor avatar occupies the existing speaker gutter, followed by the icon at the bubble's left edge and the description. In a Human DM, omit the current Bot's avatar and redundant name; Bot-DM actions show the other Bot's avatar after the icon. When the actor has no avatar (a Human, such as a Human stopping a Session), the kind icon itself sits in the speaker gutter and is not repeated, so the row shows one symbol. Stopped turns use an outlined stop-in-circle, the same symbol as the Stop item in the ⋯ menu. - Activity rows do not navigate on tap. Long-press (or the accessibility details action) opens the message sheet with the complete plain-text content in a scrollable, selectable block capped at `messageSheet.textHeightRatio` of the window height, then Copy/Share and an explicit Open conversation, Open memory or Open new Bot action when a destination exists; there are no reactions. Consecutive posts by the same Bot into the same Channel on the same day fold into a counted row whose menu opens the latest post. Memory opens the Bot's memory files, not a commit diff. Bot-to-Bot DMs remain read-only. Keep full actor/action/detail text in the accessibility label and menu even when the compact memory line uses only the icon and commit subject. diff --git a/apps/mobile/src/ui/chat/follow-latest.ts b/apps/mobile/src/ui/chat/follow-latest.ts index 1db51668ed..d6345deca0 100644 --- a/apps/mobile/src/ui/chat/follow-latest.ts +++ b/apps/mobile/src/ui/chat/follow-latest.ts @@ -12,6 +12,7 @@ export const LATEST_SLOP = 40; export const SETTLE_FRAMES = 45; export const STABLE_FRAMES = 6; export const CHECK_BUDGET = SETTLE_FRAMES * 4; +export const NATIVE_GLIDE_MS = 450; const KEYBOARD_SETTLE_MS = 1000; export const MOMENTUM_WAIT_MS = 120; @@ -100,6 +101,7 @@ export function useFollowLatest( const checks = useRef(0); const keyboardTimer = useRef | undefined>(undefined); const momentumTimer = useRef | undefined>(undefined); + const catchUpTimer = useRef | undefined>(undefined); useLayoutEffect(() => { if (!followingNow.current) onFollowingChange?.(false); }, [onFollowingChange]); @@ -111,6 +113,7 @@ export function useFollowLatest( confirmation.current = undefined; clearTimeout(keyboardTimer.current); clearTimeout(momentumTimer.current); + clearTimeout(catchUpTimer.current); }, [], ); @@ -224,6 +227,15 @@ export function useFollowLatest( pin(); }, [rowsPresent, pin]); + const catchUp = useCallback(() => { + clearTimeout(catchUpTimer.current); + catchUpTimer.current = setTimeout(() => { + catchUpTimer.current = undefined; + if (!followingNow.current || dragging.current || keyboardMoving.current) return; + if (distanceFromLatest(metrics.current, inverted) > 1) scrollToLatest(true); + }, NATIVE_GLIDE_MS); + }, [inverted, scrollToLatest]); + const onKeyboardStart = useCallback(() => { keyboardMoving.current = true; if (frame.current !== undefined) cancelAnimationFrame(frame.current); @@ -329,10 +341,13 @@ export function useFollowLatest( attempts.current = 0; if (checks.current < CHECK_BUDGET) setCanRetry(false); } - if (nativeFollow && settled.current) return; + if (nativeFollow && settled.current) { + if (height > previous) catchUp(); + return; + } pin(animateScroll && settled.current && height > previous); }, - [pin, animateScroll, nativeFollow], + [pin, animateScroll, nativeFollow, catchUp], ); const onLayout = useCallback( diff --git a/apps/mobile/test/follow-latest-gestures.test.tsx b/apps/mobile/test/follow-latest-gestures.test.tsx index 4d21066343..c145c951c3 100644 --- a/apps/mobile/test/follow-latest-gestures.test.tsx +++ b/apps/mobile/test/follow-latest-gestures.test.tsx @@ -1,1159 +1,1184 @@ -// @vitest-environment jsdom -import { act } from 'react'; -import { createRoot } from 'react-dom/client'; -import type { - FlatList, - LayoutChangeEvent, - NativeScrollEvent, - NativeSyntheticEvent, -} from 'react-native'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { ConversationMessage } from '../src/host/conversation'; -import { - CHECK_BUDGET, - MOMENTUM_WAIT_MS, - STABLE_FRAMES, - useFollowLatest, -} from '../src/ui/chat/follow-latest'; - -(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -const CONTENT = 2000; -const LAYOUT = 800; -const END = CONTENT - LAYOUT; - -const event = (distance: number, velocity = 0, content = CONTENT) => - ({ - nativeEvent: { - contentOffset: { y: content - LAYOUT - distance }, - contentSize: { height: content }, - layoutMeasurement: { height: LAYOUT }, - velocity: { y: velocity }, - }, - }) as NativeSyntheticEvent; - -const layout = (height: number) => ({ nativeEvent: { layout: { height } } }) as LayoutChangeEvent; - -let frames: Array<() => void> = []; -const flushFrames = () => - act(() => { - for (let round = 0; round < 64 && frames.length > 0; round += 1) - for (const callback of frames.splice(0)) callback(); - }); - -beforeEach(() => { - frames = []; - let nextFrame = 0; - const cancelled = new Set(); - vi.stubGlobal('requestAnimationFrame', (callback: () => void) => { - const id = ++nextFrame; - frames.push(() => { - if (!cancelled.has(id)) callback(); - }); - return id; - }); - vi.stubGlobal('cancelAnimationFrame', (id: number) => cancelled.add(id)); -}); -afterEach(() => vi.unstubAllGlobals()); - -function message(id: string, author: ConversationMessage['author']): ConversationMessage { - return { id, at: '', author, body: id, attachments: [], delivery: 'committed' }; -} - -function mount( - messages: readonly ConversationMessage[] = [], - start = 0, - empty = false, - animateScroll = false, - inverted = false, - nativeFollow = false, -) { - const following = vi.fn(); - const scrollToOffset = vi.fn(); - const list = { current: { scrollToOffset } as unknown as FlatList }; - const probe: { current?: ReturnType> } = {}; - function Probe({ rows, known }: { rows: readonly ConversationMessage[]; known: boolean }) { - probe.current = useFollowLatest( - list, - rows, - following, - start, - known && rows.length === 0, - animateScroll, - 0, - inverted, - nativeFollow, - ); - return null; - } - const root = createRoot(document.createElement('div')); - act(() => root.render()); - return { - following, - scrollToOffset, - get current() { - return probe.current!; - }, - rerender: (rows: readonly ConversationMessage[]) => - act(() => root.render()), - unmount: () => act(() => root.unmount()), - }; -} - -describe('history-reading gesture ownership', () => { - it('does not let programmatic momentum resume following after an explicit message focus', () => { - const view = mount([message('latest', 'bot')]); - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - view.current.hold(); - view.current.listProps.onMomentumScrollEnd(event(0)); - view.current.listProps.onContentSizeChange(0, CONTENT + 300); - }); - flushFrames(); - expect(view.current.away).toBe(true); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - view.unmount(); - }); - - it('keeps a replacement playback controller held when display mode changes in history', () => { - const first = vi.fn(); - const replacement = vi.fn(); - const list = { current: null }; - let current!: ReturnType>; - function Probe({ onChange }: { onChange: (following: boolean) => void }) { - current = useFollowLatest(list, [], onChange); - return null; - } - const root = createRoot(document.createElement('div')); - act(() => root.render()); - act(() => current.listProps.onScrollBeginDrag()); - act(() => current.listProps.onScrollEndDrag(event(180))); - act(() => root.render()); - expect(replacement).toHaveBeenCalledWith(false); - expect(current.away).toBe(true); - act(() => root.unmount()); - }); - - it('holds immediately and ignores a near-bottom layout event until a return gesture ends', () => { - const view = mount(); - act(() => view.current.listProps.onScrollBeginDrag()); - expect(view.following.mock.calls).toEqual([[false]]); - expect(view.current.away).toBe(true); - act(() => { - view.current.listProps.onScroll(event(180)); - view.current.listProps.onScrollEndDrag(event(180, 1)); - view.current.listProps.onScroll(event(0)); - view.current.listProps.onMomentumScrollEnd(event(180)); - }); - expect(view.following.mock.calls).toEqual([[false]]); - expect(view.current.away).toBe(true); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => { - view.current.listProps.onScrollBeginDrag(); - view.current.listProps.onScroll(event(0)); - view.current.listProps.onScrollEndDrag(event(0)); - }); - expect(view.following.mock.calls).toEqual([[false], [true]]); - expect(view.current.away).toBe(false); - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => view.current.jumpToLatest()); - expect(view.following.mock.calls).toEqual([[false], [true], [false], [true]]); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); - view.unmount(); - }); - - it('settles a flick release when no momentum follows', () => { - vi.useFakeTimers(); - const view = mount(); - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => { - view.current.listProps.onScroll(event(30)); - view.current.listProps.onScrollEndDrag(event(30, 1)); - }); - expect(view.current.away).toBe(true); - act(() => vi.advanceTimersByTime(MOMENTUM_WAIT_MS)); - expect(view.current.away).toBe(false); - view.unmount(); - vi.useRealTimers(); - }); - - it('waits for momentum to end once it begins', () => { - vi.useFakeTimers(); - const view = mount(); - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => { - view.current.listProps.onScrollEndDrag(event(300, 1)); - view.current.listProps.onMomentumScrollBegin(); - }); - act(() => vi.advanceTimersByTime(MOMENTUM_WAIT_MS * 4)); - expect(view.current.away).toBe(true); - act(() => view.current.listProps.onMomentumScrollEnd(event(10))); - expect(view.current.away).toBe(false); - view.unmount(); - vi.useRealTimers(); - }); - - it('settles a release just short of the end onto the latest message', () => { - const view = mount(); - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => view.current.listProps.onScrollEndDrag(event(30))); - expect(view.current.away).toBe(false); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); - view.unmount(); - }); -}); - -describe('following a growing conversation', () => { - it('uses scroll content geometry until the first content measurement, even after layout', () => { - const view = mount([message('latest', 'bot')]); - try { - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onScroll(event(0))); - act(() => view.current.jumpToLatest()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - act(() => view.current.listProps.onScroll(event(0, 0, CONTENT + 200))); - act(() => view.current.jumpToLatest()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); - } finally { - view.unmount(); - } - }); - - it.each([false, true])( - 'keeps measured growth with animateGrowth=%s after a stale scroll', - (animateGrowth) => { - const view = mount([message('a', 'bot')], 0, false, animateGrowth); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.scrollToOffset.mockClear(); - act(() => { - view.current.listProps.onContentSizeChange(0, CONTENT + 79); - view.current.listProps.onScroll(event(0)); - }); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ - offset: END + 79, - animated: animateGrowth, - }); - expect(view.current.away).toBe(false); - view.unmount(); - }, - ); - - it.each([ - { height: 600, offset: 1400 }, - { height: 900, offset: 1100 }, - ])( - 'pins a measured viewport height of $height despite an older scroll event', - ({ height, offset }) => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.scrollToOffset.mockClear(); - act(() => { - view.current.listProps.onLayout(layout(height)); - view.current.listProps.onScroll(event(0)); - }); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset, animated: false }); - view.unmount(); - }, - ); - - it('cancels a queued final-growth pin when the Human starts dragging', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.scrollToOffset.mockClear(); - act(() => { - view.current.listProps.onContentSizeChange(0, CONTENT + 79); - view.current.listProps.onScrollBeginDrag(); - view.current.listProps.onScrollEndDrag(event(200, 0, CONTENT + 79)); - }); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - expect(view.current.away).toBe(true); - act(() => view.current.jumpToLatest()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 79, animated: false }); - view.unmount(); - }); - - it('pins growth to the end at most once per frame without animation while following', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => { - view.current.listProps.onContentSizeChange(0, CONTENT); - view.current.listProps.onContentSizeChange(0, CONTENT + 20); - view.current.listProps.onContentSizeChange(0, CONTENT + 40); - }); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - flushFrames(); - expect(view.scrollToOffset.mock.calls).toEqual([[{ offset: END + 40, animated: false }]]); - view.unmount(); - }); - - it('never moves the viewport for growth after a drag past the slop', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - view.scrollToOffset.mockClear(); - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => view.current.listProps.onScroll(event(120))); - act(() => view.current.listProps.onScrollEndDrag(event(120))); - for (const grown of [CONTENT + 50, CONTENT + 100, CONTENT + 400]) { - act(() => view.current.listProps.onContentSizeChange(0, grown)); - flushFrames(); - } - expect(view.scrollToOffset).not.toHaveBeenCalled(); - expect(view.current.away).toBe(true); - view.unmount(); - }); - - it('counts unseen Bot messages while away and jumps back to the end', () => { - const first = [message('a', 'bot'), message('b', 'human')]; - const view = mount(first); - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => view.current.listProps.onScrollEndDrag(event(300))); - view.rerender([...first, message('c', 'bot'), message('d', 'system'), message('e', 'human')]); - expect(view.current.unseen).toBe(2); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 200)); - act(() => view.current.jumpToLatest()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 200, animated: false }); - expect(view.current.away).toBe(false); - expect(view.current.unseen).toBe(0); - view.unmount(); - }); - - it('does not count its own animated jump as leaving the latest message', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => view.current.listProps.onScrollEndDrag(event(600))); - act(() => view.current.jumpToLatest()); - act(() => { - view.current.listProps.onScroll(event(500)); - view.current.listProps.onScroll(event(200)); - }); - expect(view.current.away).toBe(false); - act(() => view.current.listProps.onMomentumScrollEnd(event(0))); - act(() => view.current.listProps.onScroll(event(200))); - expect(view.current.away).toBe(false); - act(() => view.current.listProps.onScrollBeginDrag()); - expect(view.current.away).toBe(true); - view.unmount(); - }); - - it('opens at the newest message when the list reports a scroll at the top before it is pinned', () => { - const view = mount([message('a', 'bot'), message('b', 'human')], 100); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onScroll(event(CONTENT - LAYOUT + 100))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT / 2)); - act(() => view.current.listProps.onScroll(event(CONTENT - LAYOUT, 0, CONTENT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); - expect(view.current.away).toBe(false); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.following).not.toHaveBeenCalled(); - view.unmount(); - }); - - it('leaves the latest message when the status bar scrolls the list to the top', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - view.scrollToOffset.mockClear(); - act(() => view.current.listProps.onScrollToTop()); - expect(view.current.away).toBe(true); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 100)); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - view.unmount(); - }); - - it.each([0, 100])( - 'waits for measured content after an early viewport layout (inset %s)', - (start) => { - const view = mount([message('a', 'bot')], start); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onContentSizeChange(0, 0)); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.unmount(); - }, - ); - - it('does not expose history until the native list acknowledges the requested bottom', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onScroll(event(300))); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.unmount(); - }); - - it('ignores an old native extent when newer content is still awaiting placement', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 300)); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onScroll(event(0, 0, CONTENT + 300))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.unmount(); - }); - - it('reveals the list only once the end has been pinned and the content settled', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - expect(view.current.ready).toBe(false); - act(() => frames.shift()!()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 300)); - act(() => frames.shift()!()); - expect(view.current.ready).toBe(false); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 300, animated: false }); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onScroll(event(0, 0, CONTENT + 300))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.unmount(); - }); - - it('stays hidden while rows keep rendering in batches a few frames apart', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - for (let frame = 0; frame < STABLE_FRAMES - 1; frame += 1) act(() => frames.shift()!()); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 400)); - for (let frame = 0; frame < STABLE_FRAMES - 1; frame += 1) act(() => frames.shift()!()); - expect(view.current.ready).toBe(false); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 400, animated: false }); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onScroll(event(0, 0, CONTENT + 400))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.unmount(); - }); - - it('loads older history only after the Human has dragged the list', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - expect(view.current.browsed).toBe(false); - act(() => view.current.listProps.onScrollBeginDrag()); - expect(view.current.browsed).toBe(true); - view.unmount(); - }); - - it('reveals rows that arrive after the list geometry has already settled', () => { - const view = mount([]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(false); - view.rerender([message('a', 'bot')]); - flushFrames(); - expect(view.current.ready).toBe(true); - view.unmount(); - }); - - it('offers retry when rows are present but the list never reports its content', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - for (let frame = 0; frame < CHECK_BUDGET * 2; frame += 1) - act(() => { - for (const callback of frames.splice(0)) callback(); - }); - expect(view.current.ready).toBe(false); - expect(view.current.canRetry).toBe(true); - view.unmount(); - }); - - it('pins the latest message even when a keyboard start never ends', () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const view = mount([message('a', 'bot')]); - act(() => view.current.keyboard.onStart()); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => vi.runAllTimers()); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.unmount(); - vi.useRealTimers(); - }); - - it('offers retry when the content height never stops changing', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - for (let frame = 0; frame < CHECK_BUDGET * 2; frame += 1) { - act(() => view.current.listProps.onContentSizeChange(0, frame % 2 === 0 ? 728 : 768)); - act(() => { - for (const callback of frames.splice(0)) callback(); - }); - } - expect(view.current.ready).toBe(false); - expect(view.current.canRetry).toBe(true); - view.unmount(); - }); - - it('offers retry rather than exposing unpositioned history after bounded checks', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - expect(view.current.ready).toBe(false); - expect(view.current.canRetry).toBe(true); - act(() => view.current.retry()); - expect(view.current.canRetry).toBe(false); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.current.canRetry).toBe(false); - view.unmount(); - }); - - it.each(['content', 'viewport'] as const)( - 'reveals history when the %s geometry settles after the initial retry appears', - (geometry) => { - const view = mount([message('pending', 'human')]); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - expect(view.current.ready).toBe(false); - expect(view.current.canRetry).toBe(true); - - act(() => { - if (geometry === 'content') view.current.listProps.onContentSizeChange(0, LAYOUT); - else view.current.listProps.onLayout(layout(CONTENT)); - }); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.current.canRetry).toBe(false); - } finally { - view.unmount(); - } - }, - ); - - it.each(['content', 'viewport'] as const)( - 'follows geometry reported by scroll before the %s callback', - (geometry) => { - const view = mount([message('latest', 'bot')]); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - const content = geometry === 'content' ? CONTENT + 200 : CONTENT; - const viewport = geometry === 'viewport' ? LAYOUT - 200 : LAYOUT; - view.scrollToOffset.mockClear(); - const reportGeometry = () => { - const scrollEvent = event(0); - view.current.listProps.onScroll({ - ...scrollEvent, - nativeEvent: { - ...scrollEvent.nativeEvent, - contentSize: { width: 440, height: content }, - layoutMeasurement: { width: 440, height: viewport }, - }, - }); - if (geometry === 'content') view.current.listProps.onContentSizeChange(0, content); - else view.current.listProps.onLayout(layout(viewport)); - }; - act(reportGeometry); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ - offset: END + 200, - animated: false, - }); - act(() => view.current.hold()); - view.scrollToOffset.mockClear(); - act(reportGeometry); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - expect(view.current.away).toBe(true); - } finally { - view.unmount(); - } - }, - ); - - it('keeps retry available when repeated geometry events report unchanged dimensions', () => { - const view = mount([message('pending', 'human')]); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - expect(view.current.canRetry).toBe(true); - for (let repeat = 0; repeat < 3; repeat += 1) { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - expect(view.current.canRetry).toBe(true); - flushFrames(); - expect(view.current.ready).toBe(false); - expect(view.current.canRetry).toBe(true); - } - } finally { - view.unmount(); - } - }); - - it.each(['content', 'viewport'] as const)( - 'requires fresh stable frames after the %s geometry changes during confirmation', - (geometry) => { - const view = mount([message('pending', 'human')]); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, LAYOUT); - }); - for (let frame = 0; frame < STABLE_FRAMES; frame += 1) - act(() => { - for (const callback of frames.splice(0)) callback(); - }); - expect(view.current.ready).toBe(false); - act(() => { - if (geometry === 'content') view.current.listProps.onContentSizeChange(0, LAYOUT - 100); - else view.current.listProps.onLayout(layout(LAYOUT + 100)); - }); - act(() => { - for (const callback of frames.splice(0)) callback(); - }); - expect(view.current.ready).toBe(false); - flushFrames(); - expect(view.current.ready).toBe(true); - } finally { - view.unmount(); - } - }, - ); - - it('keeps a held viewport visible without scrolling when late geometry arrives after retry', () => { - const view = mount([message('pending', 'human')]); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - expect(view.current.canRetry).toBe(true); - act(() => view.current.hold()); - view.scrollToOffset.mockClear(); - act(() => { - view.current.listProps.onLayout(layout(LAYOUT + 100)); - view.current.listProps.onContentSizeChange(0, CONTENT + 100); - }); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.current.canRetry).toBe(false); - expect(view.current.away).toBe(true); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - } finally { - view.unmount(); - } - }); - - it('reveals short content already at the initial offset without a no-op scroll event', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, LAYOUT)); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.current.canRetry).toBe(false); - view.unmount(); - }); - - it('reveals fitting Android content including its hairline without a no-op scroll event', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, LAYOUT + 0.5)); - flushFrames(); - expect(view.current.ready).toBe(true); - view.unmount(); - }); - - it('lets an explicit message-focus jump reveal history without waiting for the bottom', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - act(() => view.current.hold()); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.current.away).toBe(true); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - view.unmount(); - }); - - it('keeps a short conversation under the header instead of scrolling past its start', () => { - const view = mount([message('a', 'bot')], 100); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, LAYOUT - 100)); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: -100, animated: false }); - expect(view.current.ready).toBe(true); - act(() => view.current.listProps.onContentSizeChange(0, LAYOUT - 300)); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: -100, animated: false }); - view.unmount(); - }); - - it('keeps an empty conversation shown when its first message arrives', () => { - const view = mount([], 0, true); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, LAYOUT)); - flushFrames(); - expect(view.current.ready).toBe(true); - view.rerender([message('a', 'human')]); - expect(view.current.ready).toBe(true); - view.unmount(); - }); - - it('shows the list when the Human sends before the conversation has settled', () => { - const view = mount([]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, LAYOUT)); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => view.current.jumpToLatest()); - view.rerender([message('a', 'human')]); - expect(view.current.ready).toBe(true); - view.unmount(); - }); - - it('keeps the native anchor configured across follow and history transitions', () => { - const view = mount([message('a', 'bot')]); - expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ - minIndexForVisible: 0, - }); - act(() => view.current.listProps.onScrollBeginDrag()); - expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ - minIndexForVisible: 0, - }); - act(() => view.current.jumpToLatest()); - expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ - minIndexForVisible: 0, - }); - view.unmount(); - }); -}); - -describe('native keyboard and reading-position coordination', () => { - it('leaves scrolling to the keyboard during motion, then follows newly streamed content', () => { - const view = mount([message('latest', 'bot')]); - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - view.current.keyboard.onStart(); - view.current.keyboard.onInsetChange({ bottom: 300 }); - view.current.listProps.onContentSizeChange(0, CONTENT + 100); - }); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => view.current.keyboard.onEnd()); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 1600, animated: false }); - view.unmount(); - }); - - it('keeps a held history position through keyboard and streamed-content changes', () => { - const view = mount([message('latest', 'bot')]); - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - view.current.hold(); - view.current.keyboard.onStart(); - view.current.keyboard.onInsetChange({ bottom: 300 }); - view.current.listProps.onContentSizeChange(0, CONTENT + 100); - view.current.keyboard.onEnd(); - }); - flushFrames(); - expect(view.current.away).toBe(true); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => view.current.jumpToLatest()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 1600, animated: false }); - view.unmount(); - }); -}); - -it('animates settled streaming growth without unchanged callbacks snapping the animation', () => { - const view = mount([message('latest', 'bot')], 0, false, true); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - view.scrollToOffset.mockClear(); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 60, animated: true }); - view.scrollToOffset.mockClear(); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => view.current.hold()); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 120)); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - } finally { - view.unmount(); - } -}); - -describe('sending a message', () => { - function settled(animateScroll: boolean, nativeFollow = false) { - const view = mount([message('latest', 'bot')], 0, false, animateScroll, false, nativeFollow); - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.scrollToOffset.mockClear(); - return view; - } - - it('requests a fly at the newest message and lets the native anchor glide the growth', () => { - const view = settled(true, true); - try { - let fly = false; - act(() => { - fly = view.current.send(); - }); - expect(fly).toBe(true); - view.rerender([message('latest', 'bot'), message('sent', 'human')]); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - expect(view.current.away).toBe(false); - } finally { - view.unmount(); - } - }); - - it('requests no fly when Reduce Motion is on but still follows the new message', () => { - const view = settled(false); - try { - let fly = true; - act(() => { - fly = view.current.send(); - }); - expect(fly).toBe(false); - view.rerender([message('latest', 'bot'), message('sent', 'human')]); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 60, animated: false }); - } finally { - view.unmount(); - } - }); - - it('jumps to the newest message on send from history when the person asked for it', () => { - const view = settled(true); - try { - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => view.current.listProps.onScrollEndDrag(event(300))); - view.scrollToOffset.mockClear(); - let fly = true; - act(() => { - fly = view.current.send(true); - }); - expect(fly).toBe(false); - expect(view.current.away).toBe(false); - expect(view.scrollToOffset).toHaveBeenCalled(); - } finally { - view.unmount(); - } - }); - - it('requests no fly and leaves the scroll offset unchanged while reading history', () => { - const view = settled(true); - try { - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => view.current.listProps.onScrollEndDrag(event(300))); - view.scrollToOffset.mockClear(); - let fly = true; - act(() => { - fly = view.current.send(); - }); - expect(fly).toBe(false); - view.rerender([message('latest', 'bot'), message('sent', 'human')]); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - expect(view.current.away).toBe(true); - } finally { - view.unmount(); - } - }); -}); - -it('leaves settled growth to the native latest anchor but still pins the first placement', () => { - const view = mount([message('latest', 'bot')], 0, false, true, false, true); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenCalledWith({ offset: END, animated: false }); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.scrollToOffset.mockClear(); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => view.current.jumpToLatest()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 60, animated: true }); - } finally { - view.unmount(); - } -}); - -it('retains Android mVCP while native following owns growth and JS owns explicit jumps', () => { - const view = mount([message('latest', 'bot')], 0, false, true, true, true); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); - expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ - minIndexForVisible: 0, - }); - view.scrollToOffset.mockClear(); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => view.current.listProps.onScrollBeginDrag()); - expect(view.current.away).toBe(true); - expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ - minIndexForVisible: 0, - }); - act(() => view.current.jumpToLatest()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: true }); - expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ - minIndexForVisible: 0, - }); - } finally { - view.unmount(); - } -}); - -it('keeps native keyboard ownership even when streaming growth is animated', () => { - const view = mount([message('latest', 'bot')], 0, false, true); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - view.current.jumpToLatest(); - }); - flushFrames(); - view.scrollToOffset.mockClear(); - act(() => { - view.current.keyboard.onStart(); - view.current.listProps.onContentSizeChange(0, CONTENT + 60); - }); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => view.current.keyboard.onEnd()); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 60, animated: false }); - } finally { - view.unmount(); - } -}); - -describe('latest-first Android list', () => { - it('reveals measured latest-first content after positioning without waiting for older-row sizes', () => { - const view = mount([message('latest', 'bot')], 0, false, true, true); - try { - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - act(() => { - for (const callback of frames.splice(0)) callback(); - }); - expect(view.current.ready).toBe(false); - act(() => { - for (const callback of frames.splice(0)) callback(); - }); - expect(view.current.ready).toBe(true); - expect(view.current.canRetry).toBe(false); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 900)); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: true }); - } finally { - view.unmount(); - } - }); - - it('does not reveal an inverted viewport away from zero and recovers after retry', () => { - const view = mount([message('latest', 'bot')], 0, false, true, true); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - view.current.listProps.onScroll(event(END - 180)); - }); - flushFrames(); - expect(view.current.ready).toBe(false); - expect(view.current.canRetry).toBe(true); - act(() => { - view.current.retry(); - view.current.listProps.onScroll(event(END)); - }); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.current.canRetry).toBe(false); - } finally { - view.unmount(); - } - }); - - it('waits for keyboard handoff before revealing an initial inverted viewport', () => { - const view = mount([message('latest', 'bot')], 0, false, true, true); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - act(() => { - for (const callback of frames.splice(0)) callback(); - }); - act(() => view.current.keyboard.onStart()); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => { - view.current.keyboard.onInsetChange({ bottom: 300 }); - view.current.listProps.onLayout(layout(LAYOUT - 300)); - view.current.keyboard.onEnd(); - }); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); - } finally { - view.unmount(); - } - }); - - it('positions at zero without measuring all older rows', () => { - const view = mount([message('latest', 'bot')], 0, false, true, true); - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); - view.unmount(); - }); - it('holds reading while content grows and explicitly returns to zero', () => { - const view = mount([message('latest', 'bot')], 0, false, true, true); - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - view.scrollToOffset.mockClear(); - act(() => { - view.current.listProps.onScrollBeginDrag(); - view.current.listProps.onScrollEndDrag(event(END - 180)); - view.current.listProps.onContentSizeChange(0, CONTENT + 300); - }); - flushFrames(); - expect(view.current.away).toBe(true); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => view.current.jumpToLatest()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: true }); - view.unmount(); - }); - it('keeps latest at zero after keyboard handoff', () => { - const view = mount([message('latest', 'bot')], 0, false, true, true); - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - view.scrollToOffset.mockClear(); - act(() => { - view.current.keyboard.onStart(); - view.current.keyboard.onInsetChange({ bottom: 300 }); - view.current.listProps.onContentSizeChange(0, CONTENT + 400); - }); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => view.current.keyboard.onEnd()); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); - view.unmount(); - }); -}); +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import type { + FlatList, + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, +} from 'react-native'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ConversationMessage } from '../src/host/conversation'; +import { + CHECK_BUDGET, + MOMENTUM_WAIT_MS, + NATIVE_GLIDE_MS, + STABLE_FRAMES, + useFollowLatest, +} from '../src/ui/chat/follow-latest'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const CONTENT = 2000; +const LAYOUT = 800; +const END = CONTENT - LAYOUT; + +const event = (distance: number, velocity = 0, content = CONTENT) => + ({ + nativeEvent: { + contentOffset: { y: content - LAYOUT - distance }, + contentSize: { height: content }, + layoutMeasurement: { height: LAYOUT }, + velocity: { y: velocity }, + }, + }) as NativeSyntheticEvent; + +const layout = (height: number) => ({ nativeEvent: { layout: { height } } }) as LayoutChangeEvent; + +let frames: Array<() => void> = []; +const flushFrames = () => + act(() => { + for (let round = 0; round < 64 && frames.length > 0; round += 1) + for (const callback of frames.splice(0)) callback(); + }); + +beforeEach(() => { + frames = []; + let nextFrame = 0; + const cancelled = new Set(); + vi.stubGlobal('requestAnimationFrame', (callback: () => void) => { + const id = ++nextFrame; + frames.push(() => { + if (!cancelled.has(id)) callback(); + }); + return id; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => cancelled.add(id)); +}); +afterEach(() => vi.unstubAllGlobals()); + +function message(id: string, author: ConversationMessage['author']): ConversationMessage { + return { id, at: '', author, body: id, attachments: [], delivery: 'committed' }; +} + +function mount( + messages: readonly ConversationMessage[] = [], + start = 0, + empty = false, + animateScroll = false, + inverted = false, + nativeFollow = false, +) { + const following = vi.fn(); + const scrollToOffset = vi.fn(); + const list = { current: { scrollToOffset } as unknown as FlatList }; + const probe: { current?: ReturnType> } = {}; + function Probe({ rows, known }: { rows: readonly ConversationMessage[]; known: boolean }) { + probe.current = useFollowLatest( + list, + rows, + following, + start, + known && rows.length === 0, + animateScroll, + 0, + inverted, + nativeFollow, + ); + return null; + } + const root = createRoot(document.createElement('div')); + act(() => root.render()); + return { + following, + scrollToOffset, + get current() { + return probe.current!; + }, + rerender: (rows: readonly ConversationMessage[]) => + act(() => root.render()), + unmount: () => act(() => root.unmount()), + }; +} + +describe('history-reading gesture ownership', () => { + it('does not let programmatic momentum resume following after an explicit message focus', () => { + const view = mount([message('latest', 'bot')]); + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + view.current.hold(); + view.current.listProps.onMomentumScrollEnd(event(0)); + view.current.listProps.onContentSizeChange(0, CONTENT + 300); + }); + flushFrames(); + expect(view.current.away).toBe(true); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + view.unmount(); + }); + + it('keeps a replacement playback controller held when display mode changes in history', () => { + const first = vi.fn(); + const replacement = vi.fn(); + const list = { current: null }; + let current!: ReturnType>; + function Probe({ onChange }: { onChange: (following: boolean) => void }) { + current = useFollowLatest(list, [], onChange); + return null; + } + const root = createRoot(document.createElement('div')); + act(() => root.render()); + act(() => current.listProps.onScrollBeginDrag()); + act(() => current.listProps.onScrollEndDrag(event(180))); + act(() => root.render()); + expect(replacement).toHaveBeenCalledWith(false); + expect(current.away).toBe(true); + act(() => root.unmount()); + }); + + it('holds immediately and ignores a near-bottom layout event until a return gesture ends', () => { + const view = mount(); + act(() => view.current.listProps.onScrollBeginDrag()); + expect(view.following.mock.calls).toEqual([[false]]); + expect(view.current.away).toBe(true); + act(() => { + view.current.listProps.onScroll(event(180)); + view.current.listProps.onScrollEndDrag(event(180, 1)); + view.current.listProps.onScroll(event(0)); + view.current.listProps.onMomentumScrollEnd(event(180)); + }); + expect(view.following.mock.calls).toEqual([[false]]); + expect(view.current.away).toBe(true); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => { + view.current.listProps.onScrollBeginDrag(); + view.current.listProps.onScroll(event(0)); + view.current.listProps.onScrollEndDrag(event(0)); + }); + expect(view.following.mock.calls).toEqual([[false], [true]]); + expect(view.current.away).toBe(false); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.jumpToLatest()); + expect(view.following.mock.calls).toEqual([[false], [true], [false], [true]]); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); + view.unmount(); + }); + + it('settles a flick release when no momentum follows', () => { + vi.useFakeTimers(); + const view = mount(); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => { + view.current.listProps.onScroll(event(30)); + view.current.listProps.onScrollEndDrag(event(30, 1)); + }); + expect(view.current.away).toBe(true); + act(() => vi.advanceTimersByTime(MOMENTUM_WAIT_MS)); + expect(view.current.away).toBe(false); + view.unmount(); + vi.useRealTimers(); + }); + + it('waits for momentum to end once it begins', () => { + vi.useFakeTimers(); + const view = mount(); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => { + view.current.listProps.onScrollEndDrag(event(300, 1)); + view.current.listProps.onMomentumScrollBegin(); + }); + act(() => vi.advanceTimersByTime(MOMENTUM_WAIT_MS * 4)); + expect(view.current.away).toBe(true); + act(() => view.current.listProps.onMomentumScrollEnd(event(10))); + expect(view.current.away).toBe(false); + view.unmount(); + vi.useRealTimers(); + }); + + it('settles a release just short of the end onto the latest message', () => { + const view = mount(); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.listProps.onScrollEndDrag(event(30))); + expect(view.current.away).toBe(false); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); + view.unmount(); + }); +}); + +describe('following a growing conversation', () => { + it('uses scroll content geometry until the first content measurement, even after layout', () => { + const view = mount([message('latest', 'bot')]); + try { + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onScroll(event(0))); + act(() => view.current.jumpToLatest()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + act(() => view.current.listProps.onScroll(event(0, 0, CONTENT + 200))); + act(() => view.current.jumpToLatest()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); + } finally { + view.unmount(); + } + }); + + it.each([false, true])( + 'keeps measured growth with animateGrowth=%s after a stale scroll', + (animateGrowth) => { + const view = mount([message('a', 'bot')], 0, false, animateGrowth); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.scrollToOffset.mockClear(); + act(() => { + view.current.listProps.onContentSizeChange(0, CONTENT + 79); + view.current.listProps.onScroll(event(0)); + }); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ + offset: END + 79, + animated: animateGrowth, + }); + expect(view.current.away).toBe(false); + view.unmount(); + }, + ); + + it.each([ + { height: 600, offset: 1400 }, + { height: 900, offset: 1100 }, + ])( + 'pins a measured viewport height of $height despite an older scroll event', + ({ height, offset }) => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.scrollToOffset.mockClear(); + act(() => { + view.current.listProps.onLayout(layout(height)); + view.current.listProps.onScroll(event(0)); + }); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset, animated: false }); + view.unmount(); + }, + ); + + it('cancels a queued final-growth pin when the Human starts dragging', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.scrollToOffset.mockClear(); + act(() => { + view.current.listProps.onContentSizeChange(0, CONTENT + 79); + view.current.listProps.onScrollBeginDrag(); + view.current.listProps.onScrollEndDrag(event(200, 0, CONTENT + 79)); + }); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + expect(view.current.away).toBe(true); + act(() => view.current.jumpToLatest()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 79, animated: false }); + view.unmount(); + }); + + it('pins growth to the end at most once per frame without animation while following', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => { + view.current.listProps.onContentSizeChange(0, CONTENT); + view.current.listProps.onContentSizeChange(0, CONTENT + 20); + view.current.listProps.onContentSizeChange(0, CONTENT + 40); + }); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + flushFrames(); + expect(view.scrollToOffset.mock.calls).toEqual([[{ offset: END + 40, animated: false }]]); + view.unmount(); + }); + + it('never moves the viewport for growth after a drag past the slop', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + view.scrollToOffset.mockClear(); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.listProps.onScroll(event(120))); + act(() => view.current.listProps.onScrollEndDrag(event(120))); + for (const grown of [CONTENT + 50, CONTENT + 100, CONTENT + 400]) { + act(() => view.current.listProps.onContentSizeChange(0, grown)); + flushFrames(); + } + expect(view.scrollToOffset).not.toHaveBeenCalled(); + expect(view.current.away).toBe(true); + view.unmount(); + }); + + it('counts unseen Bot messages while away and jumps back to the end', () => { + const first = [message('a', 'bot'), message('b', 'human')]; + const view = mount(first); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.listProps.onScrollEndDrag(event(300))); + view.rerender([...first, message('c', 'bot'), message('d', 'system'), message('e', 'human')]); + expect(view.current.unseen).toBe(2); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 200)); + act(() => view.current.jumpToLatest()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 200, animated: false }); + expect(view.current.away).toBe(false); + expect(view.current.unseen).toBe(0); + view.unmount(); + }); + + it('does not count its own animated jump as leaving the latest message', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.listProps.onScrollEndDrag(event(600))); + act(() => view.current.jumpToLatest()); + act(() => { + view.current.listProps.onScroll(event(500)); + view.current.listProps.onScroll(event(200)); + }); + expect(view.current.away).toBe(false); + act(() => view.current.listProps.onMomentumScrollEnd(event(0))); + act(() => view.current.listProps.onScroll(event(200))); + expect(view.current.away).toBe(false); + act(() => view.current.listProps.onScrollBeginDrag()); + expect(view.current.away).toBe(true); + view.unmount(); + }); + + it('opens at the newest message when the list reports a scroll at the top before it is pinned', () => { + const view = mount([message('a', 'bot'), message('b', 'human')], 100); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onScroll(event(CONTENT - LAYOUT + 100))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT / 2)); + act(() => view.current.listProps.onScroll(event(CONTENT - LAYOUT, 0, CONTENT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); + expect(view.current.away).toBe(false); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.following).not.toHaveBeenCalled(); + view.unmount(); + }); + + it('leaves the latest message when the status bar scrolls the list to the top', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + view.scrollToOffset.mockClear(); + act(() => view.current.listProps.onScrollToTop()); + expect(view.current.away).toBe(true); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 100)); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + view.unmount(); + }); + + it.each([0, 100])( + 'waits for measured content after an early viewport layout (inset %s)', + (start) => { + const view = mount([message('a', 'bot')], start); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onContentSizeChange(0, 0)); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.unmount(); + }, + ); + + it('does not expose history until the native list acknowledges the requested bottom', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onScroll(event(300))); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('ignores an old native extent when newer content is still awaiting placement', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 300)); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onScroll(event(0, 0, CONTENT + 300))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('reveals the list only once the end has been pinned and the content settled', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + expect(view.current.ready).toBe(false); + act(() => frames.shift()!()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 300)); + act(() => frames.shift()!()); + expect(view.current.ready).toBe(false); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 300, animated: false }); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onScroll(event(0, 0, CONTENT + 300))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('stays hidden while rows keep rendering in batches a few frames apart', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + for (let frame = 0; frame < STABLE_FRAMES - 1; frame += 1) act(() => frames.shift()!()); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 400)); + for (let frame = 0; frame < STABLE_FRAMES - 1; frame += 1) act(() => frames.shift()!()); + expect(view.current.ready).toBe(false); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 400, animated: false }); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onScroll(event(0, 0, CONTENT + 400))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('loads older history only after the Human has dragged the list', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + expect(view.current.browsed).toBe(false); + act(() => view.current.listProps.onScrollBeginDrag()); + expect(view.current.browsed).toBe(true); + view.unmount(); + }); + + it('reveals rows that arrive after the list geometry has already settled', () => { + const view = mount([]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(false); + view.rerender([message('a', 'bot')]); + flushFrames(); + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('offers retry when rows are present but the list never reports its content', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + for (let frame = 0; frame < CHECK_BUDGET * 2; frame += 1) + act(() => { + for (const callback of frames.splice(0)) callback(); + }); + expect(view.current.ready).toBe(false); + expect(view.current.canRetry).toBe(true); + view.unmount(); + }); + + it('pins the latest message even when a keyboard start never ends', () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const view = mount([message('a', 'bot')]); + act(() => view.current.keyboard.onStart()); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => vi.runAllTimers()); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.unmount(); + vi.useRealTimers(); + }); + + it('offers retry when the content height never stops changing', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + for (let frame = 0; frame < CHECK_BUDGET * 2; frame += 1) { + act(() => view.current.listProps.onContentSizeChange(0, frame % 2 === 0 ? 728 : 768)); + act(() => { + for (const callback of frames.splice(0)) callback(); + }); + } + expect(view.current.ready).toBe(false); + expect(view.current.canRetry).toBe(true); + view.unmount(); + }); + + it('offers retry rather than exposing unpositioned history after bounded checks', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + expect(view.current.ready).toBe(false); + expect(view.current.canRetry).toBe(true); + act(() => view.current.retry()); + expect(view.current.canRetry).toBe(false); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.current.canRetry).toBe(false); + view.unmount(); + }); + + it.each(['content', 'viewport'] as const)( + 'reveals history when the %s geometry settles after the initial retry appears', + (geometry) => { + const view = mount([message('pending', 'human')]); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + expect(view.current.ready).toBe(false); + expect(view.current.canRetry).toBe(true); + + act(() => { + if (geometry === 'content') view.current.listProps.onContentSizeChange(0, LAYOUT); + else view.current.listProps.onLayout(layout(CONTENT)); + }); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.current.canRetry).toBe(false); + } finally { + view.unmount(); + } + }, + ); + + it.each(['content', 'viewport'] as const)( + 'follows geometry reported by scroll before the %s callback', + (geometry) => { + const view = mount([message('latest', 'bot')]); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + const content = geometry === 'content' ? CONTENT + 200 : CONTENT; + const viewport = geometry === 'viewport' ? LAYOUT - 200 : LAYOUT; + view.scrollToOffset.mockClear(); + const reportGeometry = () => { + const scrollEvent = event(0); + view.current.listProps.onScroll({ + ...scrollEvent, + nativeEvent: { + ...scrollEvent.nativeEvent, + contentSize: { width: 440, height: content }, + layoutMeasurement: { width: 440, height: viewport }, + }, + }); + if (geometry === 'content') view.current.listProps.onContentSizeChange(0, content); + else view.current.listProps.onLayout(layout(viewport)); + }; + act(reportGeometry); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ + offset: END + 200, + animated: false, + }); + act(() => view.current.hold()); + view.scrollToOffset.mockClear(); + act(reportGeometry); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + expect(view.current.away).toBe(true); + } finally { + view.unmount(); + } + }, + ); + + it('keeps retry available when repeated geometry events report unchanged dimensions', () => { + const view = mount([message('pending', 'human')]); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + expect(view.current.canRetry).toBe(true); + for (let repeat = 0; repeat < 3; repeat += 1) { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + expect(view.current.canRetry).toBe(true); + flushFrames(); + expect(view.current.ready).toBe(false); + expect(view.current.canRetry).toBe(true); + } + } finally { + view.unmount(); + } + }); + + it.each(['content', 'viewport'] as const)( + 'requires fresh stable frames after the %s geometry changes during confirmation', + (geometry) => { + const view = mount([message('pending', 'human')]); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, LAYOUT); + }); + for (let frame = 0; frame < STABLE_FRAMES; frame += 1) + act(() => { + for (const callback of frames.splice(0)) callback(); + }); + expect(view.current.ready).toBe(false); + act(() => { + if (geometry === 'content') view.current.listProps.onContentSizeChange(0, LAYOUT - 100); + else view.current.listProps.onLayout(layout(LAYOUT + 100)); + }); + act(() => { + for (const callback of frames.splice(0)) callback(); + }); + expect(view.current.ready).toBe(false); + flushFrames(); + expect(view.current.ready).toBe(true); + } finally { + view.unmount(); + } + }, + ); + + it('keeps a held viewport visible without scrolling when late geometry arrives after retry', () => { + const view = mount([message('pending', 'human')]); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + expect(view.current.canRetry).toBe(true); + act(() => view.current.hold()); + view.scrollToOffset.mockClear(); + act(() => { + view.current.listProps.onLayout(layout(LAYOUT + 100)); + view.current.listProps.onContentSizeChange(0, CONTENT + 100); + }); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.current.canRetry).toBe(false); + expect(view.current.away).toBe(true); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + } finally { + view.unmount(); + } + }); + + it('reveals short content already at the initial offset without a no-op scroll event', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, LAYOUT)); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.current.canRetry).toBe(false); + view.unmount(); + }); + + it('reveals fitting Android content including its hairline without a no-op scroll event', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, LAYOUT + 0.5)); + flushFrames(); + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('lets an explicit message-focus jump reveal history without waiting for the bottom', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + act(() => view.current.hold()); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.current.away).toBe(true); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + view.unmount(); + }); + + it('keeps a short conversation under the header instead of scrolling past its start', () => { + const view = mount([message('a', 'bot')], 100); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, LAYOUT - 100)); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: -100, animated: false }); + expect(view.current.ready).toBe(true); + act(() => view.current.listProps.onContentSizeChange(0, LAYOUT - 300)); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: -100, animated: false }); + view.unmount(); + }); + + it('keeps an empty conversation shown when its first message arrives', () => { + const view = mount([], 0, true); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, LAYOUT)); + flushFrames(); + expect(view.current.ready).toBe(true); + view.rerender([message('a', 'human')]); + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('shows the list when the Human sends before the conversation has settled', () => { + const view = mount([]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, LAYOUT)); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => view.current.jumpToLatest()); + view.rerender([message('a', 'human')]); + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('keeps the native anchor configured across follow and history transitions', () => { + const view = mount([message('a', 'bot')]); + expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ + minIndexForVisible: 0, + }); + act(() => view.current.listProps.onScrollBeginDrag()); + expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ + minIndexForVisible: 0, + }); + act(() => view.current.jumpToLatest()); + expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ + minIndexForVisible: 0, + }); + view.unmount(); + }); +}); + +describe('native keyboard and reading-position coordination', () => { + it('leaves scrolling to the keyboard during motion, then follows newly streamed content', () => { + const view = mount([message('latest', 'bot')]); + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + view.current.keyboard.onStart(); + view.current.keyboard.onInsetChange({ bottom: 300 }); + view.current.listProps.onContentSizeChange(0, CONTENT + 100); + }); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => view.current.keyboard.onEnd()); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 1600, animated: false }); + view.unmount(); + }); + + it('keeps a held history position through keyboard and streamed-content changes', () => { + const view = mount([message('latest', 'bot')]); + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + view.current.hold(); + view.current.keyboard.onStart(); + view.current.keyboard.onInsetChange({ bottom: 300 }); + view.current.listProps.onContentSizeChange(0, CONTENT + 100); + view.current.keyboard.onEnd(); + }); + flushFrames(); + expect(view.current.away).toBe(true); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => view.current.jumpToLatest()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 1600, animated: false }); + view.unmount(); + }); +}); + +it('animates settled streaming growth without unchanged callbacks snapping the animation', () => { + const view = mount([message('latest', 'bot')], 0, false, true); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + view.scrollToOffset.mockClear(); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 60, animated: true }); + view.scrollToOffset.mockClear(); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => view.current.hold()); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 120)); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + } finally { + view.unmount(); + } +}); + +describe('sending a message', () => { + function settled(animateScroll: boolean, nativeFollow = false) { + const view = mount([message('latest', 'bot')], 0, false, animateScroll, false, nativeFollow); + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.scrollToOffset.mockClear(); + return view; + } + + it('requests a fly at the newest message and lets the native anchor glide the growth', () => { + const view = settled(true, true); + try { + let fly = false; + act(() => { + fly = view.current.send(); + }); + expect(fly).toBe(true); + view.rerender([message('latest', 'bot'), message('sent', 'human')]); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + expect(view.current.away).toBe(false); + } finally { + view.unmount(); + } + }); + + it('finishes the glide when the native anchor stops short of the newest message', () => { + const view = settled(true, true); + vi.useFakeTimers(); + try { + act(() => { + view.current.send(); + }); + view.rerender([message('latest', 'bot'), message('sent', 'human')]); + act(() => { + view.current.listProps.onContentSizeChange(0, CONTENT + 60); + view.current.listProps.onScroll(event(48, 0, CONTENT + 60)); + }); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(NATIVE_GLIDE_MS)); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ + offset: CONTENT + 60 - LAYOUT, + animated: true, + }); + } finally { + view.unmount(); + vi.useRealTimers(); + } + }); + + it('requests no fly when Reduce Motion is on but still follows the new message', () => { + const view = settled(false); + try { + let fly = true; + act(() => { + fly = view.current.send(); + }); + expect(fly).toBe(false); + view.rerender([message('latest', 'bot'), message('sent', 'human')]); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 60, animated: false }); + } finally { + view.unmount(); + } + }); + + it('jumps to the newest message on send from history when the person asked for it', () => { + const view = settled(true); + try { + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.listProps.onScrollEndDrag(event(300))); + view.scrollToOffset.mockClear(); + let fly = true; + act(() => { + fly = view.current.send(true); + }); + expect(fly).toBe(false); + expect(view.current.away).toBe(false); + expect(view.scrollToOffset).toHaveBeenCalled(); + } finally { + view.unmount(); + } + }); + + it('requests no fly and leaves the scroll offset unchanged while reading history', () => { + const view = settled(true); + try { + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.listProps.onScrollEndDrag(event(300))); + view.scrollToOffset.mockClear(); + let fly = true; + act(() => { + fly = view.current.send(); + }); + expect(fly).toBe(false); + view.rerender([message('latest', 'bot'), message('sent', 'human')]); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + expect(view.current.away).toBe(true); + } finally { + view.unmount(); + } + }); +}); + +it('leaves settled growth to the native latest anchor but still pins the first placement', () => { + const view = mount([message('latest', 'bot')], 0, false, true, false, true); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenCalledWith({ offset: END, animated: false }); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.scrollToOffset.mockClear(); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => view.current.jumpToLatest()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 60, animated: true }); + } finally { + view.unmount(); + } +}); + +it('retains Android mVCP while native following owns growth and JS owns explicit jumps', () => { + const view = mount([message('latest', 'bot')], 0, false, true, true, true); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); + expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ + minIndexForVisible: 0, + }); + view.scrollToOffset.mockClear(); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => view.current.listProps.onScrollBeginDrag()); + expect(view.current.away).toBe(true); + expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ + minIndexForVisible: 0, + }); + act(() => view.current.jumpToLatest()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: true }); + expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ + minIndexForVisible: 0, + }); + } finally { + view.unmount(); + } +}); + +it('keeps native keyboard ownership even when streaming growth is animated', () => { + const view = mount([message('latest', 'bot')], 0, false, true); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + view.current.jumpToLatest(); + }); + flushFrames(); + view.scrollToOffset.mockClear(); + act(() => { + view.current.keyboard.onStart(); + view.current.listProps.onContentSizeChange(0, CONTENT + 60); + }); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => view.current.keyboard.onEnd()); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 60, animated: false }); + } finally { + view.unmount(); + } +}); + +describe('latest-first Android list', () => { + it('reveals measured latest-first content after positioning without waiting for older-row sizes', () => { + const view = mount([message('latest', 'bot')], 0, false, true, true); + try { + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + act(() => { + for (const callback of frames.splice(0)) callback(); + }); + expect(view.current.ready).toBe(false); + act(() => { + for (const callback of frames.splice(0)) callback(); + }); + expect(view.current.ready).toBe(true); + expect(view.current.canRetry).toBe(false); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 900)); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: true }); + } finally { + view.unmount(); + } + }); + + it('does not reveal an inverted viewport away from zero and recovers after retry', () => { + const view = mount([message('latest', 'bot')], 0, false, true, true); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + view.current.listProps.onScroll(event(END - 180)); + }); + flushFrames(); + expect(view.current.ready).toBe(false); + expect(view.current.canRetry).toBe(true); + act(() => { + view.current.retry(); + view.current.listProps.onScroll(event(END)); + }); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.current.canRetry).toBe(false); + } finally { + view.unmount(); + } + }); + + it('waits for keyboard handoff before revealing an initial inverted viewport', () => { + const view = mount([message('latest', 'bot')], 0, false, true, true); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + act(() => { + for (const callback of frames.splice(0)) callback(); + }); + act(() => view.current.keyboard.onStart()); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => { + view.current.keyboard.onInsetChange({ bottom: 300 }); + view.current.listProps.onLayout(layout(LAYOUT - 300)); + view.current.keyboard.onEnd(); + }); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); + } finally { + view.unmount(); + } + }); + + it('positions at zero without measuring all older rows', () => { + const view = mount([message('latest', 'bot')], 0, false, true, true); + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); + view.unmount(); + }); + it('holds reading while content grows and explicitly returns to zero', () => { + const view = mount([message('latest', 'bot')], 0, false, true, true); + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + view.scrollToOffset.mockClear(); + act(() => { + view.current.listProps.onScrollBeginDrag(); + view.current.listProps.onScrollEndDrag(event(END - 180)); + view.current.listProps.onContentSizeChange(0, CONTENT + 300); + }); + flushFrames(); + expect(view.current.away).toBe(true); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => view.current.jumpToLatest()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: true }); + view.unmount(); + }); + it('keeps latest at zero after keyboard handoff', () => { + const view = mount([message('latest', 'bot')], 0, false, true, true); + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + view.scrollToOffset.mockClear(); + act(() => { + view.current.keyboard.onStart(); + view.current.keyboard.onInsetChange({ bottom: 300 }); + view.current.listProps.onContentSizeChange(0, CONTENT + 400); + }); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => view.current.keyboard.onEnd()); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); + view.unmount(); + }); +}); From c19abfc0b13ee1f764a4e48b47287ff4767098ad Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Wed, 11 Nov 2026 21:55:44 +0900 Subject: [PATCH 3/6] fix(mobile): catch up to the newest message when a send shrinks the content A send can leave the list's content slightly shorter while keeping the visible row in place shifts the scroll off the newest message. Neither the native glide nor the catch-up reacted to a shrink, so the new message rested half behind the composer. The catch-up now runs on any content height change while following. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/ui/chat/follow-latest.ts | 2 +- .../test/follow-latest-gestures.test.tsx | 2391 +++++++++-------- 2 files changed, 1208 insertions(+), 1185 deletions(-) diff --git a/apps/mobile/src/ui/chat/follow-latest.ts b/apps/mobile/src/ui/chat/follow-latest.ts index d6345deca0..becc71a5a3 100644 --- a/apps/mobile/src/ui/chat/follow-latest.ts +++ b/apps/mobile/src/ui/chat/follow-latest.ts @@ -342,7 +342,7 @@ export function useFollowLatest( if (checks.current < CHECK_BUDGET) setCanRetry(false); } if (nativeFollow && settled.current) { - if (height > previous) catchUp(); + if (height !== previous) catchUp(); return; } pin(animateScroll && settled.current && height > previous); diff --git a/apps/mobile/test/follow-latest-gestures.test.tsx b/apps/mobile/test/follow-latest-gestures.test.tsx index c145c951c3..0a070a2d44 100644 --- a/apps/mobile/test/follow-latest-gestures.test.tsx +++ b/apps/mobile/test/follow-latest-gestures.test.tsx @@ -1,1184 +1,1207 @@ -// @vitest-environment jsdom -import { act } from 'react'; -import { createRoot } from 'react-dom/client'; -import type { - FlatList, - LayoutChangeEvent, - NativeScrollEvent, - NativeSyntheticEvent, -} from 'react-native'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { ConversationMessage } from '../src/host/conversation'; -import { - CHECK_BUDGET, - MOMENTUM_WAIT_MS, - NATIVE_GLIDE_MS, - STABLE_FRAMES, - useFollowLatest, -} from '../src/ui/chat/follow-latest'; - -(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -const CONTENT = 2000; -const LAYOUT = 800; -const END = CONTENT - LAYOUT; - -const event = (distance: number, velocity = 0, content = CONTENT) => - ({ - nativeEvent: { - contentOffset: { y: content - LAYOUT - distance }, - contentSize: { height: content }, - layoutMeasurement: { height: LAYOUT }, - velocity: { y: velocity }, - }, - }) as NativeSyntheticEvent; - -const layout = (height: number) => ({ nativeEvent: { layout: { height } } }) as LayoutChangeEvent; - -let frames: Array<() => void> = []; -const flushFrames = () => - act(() => { - for (let round = 0; round < 64 && frames.length > 0; round += 1) - for (const callback of frames.splice(0)) callback(); - }); - -beforeEach(() => { - frames = []; - let nextFrame = 0; - const cancelled = new Set(); - vi.stubGlobal('requestAnimationFrame', (callback: () => void) => { - const id = ++nextFrame; - frames.push(() => { - if (!cancelled.has(id)) callback(); - }); - return id; - }); - vi.stubGlobal('cancelAnimationFrame', (id: number) => cancelled.add(id)); -}); -afterEach(() => vi.unstubAllGlobals()); - -function message(id: string, author: ConversationMessage['author']): ConversationMessage { - return { id, at: '', author, body: id, attachments: [], delivery: 'committed' }; -} - -function mount( - messages: readonly ConversationMessage[] = [], - start = 0, - empty = false, - animateScroll = false, - inverted = false, - nativeFollow = false, -) { - const following = vi.fn(); - const scrollToOffset = vi.fn(); - const list = { current: { scrollToOffset } as unknown as FlatList }; - const probe: { current?: ReturnType> } = {}; - function Probe({ rows, known }: { rows: readonly ConversationMessage[]; known: boolean }) { - probe.current = useFollowLatest( - list, - rows, - following, - start, - known && rows.length === 0, - animateScroll, - 0, - inverted, - nativeFollow, - ); - return null; - } - const root = createRoot(document.createElement('div')); - act(() => root.render()); - return { - following, - scrollToOffset, - get current() { - return probe.current!; - }, - rerender: (rows: readonly ConversationMessage[]) => - act(() => root.render()), - unmount: () => act(() => root.unmount()), - }; -} - -describe('history-reading gesture ownership', () => { - it('does not let programmatic momentum resume following after an explicit message focus', () => { - const view = mount([message('latest', 'bot')]); - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - view.current.hold(); - view.current.listProps.onMomentumScrollEnd(event(0)); - view.current.listProps.onContentSizeChange(0, CONTENT + 300); - }); - flushFrames(); - expect(view.current.away).toBe(true); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - view.unmount(); - }); - - it('keeps a replacement playback controller held when display mode changes in history', () => { - const first = vi.fn(); - const replacement = vi.fn(); - const list = { current: null }; - let current!: ReturnType>; - function Probe({ onChange }: { onChange: (following: boolean) => void }) { - current = useFollowLatest(list, [], onChange); - return null; - } - const root = createRoot(document.createElement('div')); - act(() => root.render()); - act(() => current.listProps.onScrollBeginDrag()); - act(() => current.listProps.onScrollEndDrag(event(180))); - act(() => root.render()); - expect(replacement).toHaveBeenCalledWith(false); - expect(current.away).toBe(true); - act(() => root.unmount()); - }); - - it('holds immediately and ignores a near-bottom layout event until a return gesture ends', () => { - const view = mount(); - act(() => view.current.listProps.onScrollBeginDrag()); - expect(view.following.mock.calls).toEqual([[false]]); - expect(view.current.away).toBe(true); - act(() => { - view.current.listProps.onScroll(event(180)); - view.current.listProps.onScrollEndDrag(event(180, 1)); - view.current.listProps.onScroll(event(0)); - view.current.listProps.onMomentumScrollEnd(event(180)); - }); - expect(view.following.mock.calls).toEqual([[false]]); - expect(view.current.away).toBe(true); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => { - view.current.listProps.onScrollBeginDrag(); - view.current.listProps.onScroll(event(0)); - view.current.listProps.onScrollEndDrag(event(0)); - }); - expect(view.following.mock.calls).toEqual([[false], [true]]); - expect(view.current.away).toBe(false); - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => view.current.jumpToLatest()); - expect(view.following.mock.calls).toEqual([[false], [true], [false], [true]]); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); - view.unmount(); - }); - - it('settles a flick release when no momentum follows', () => { - vi.useFakeTimers(); - const view = mount(); - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => { - view.current.listProps.onScroll(event(30)); - view.current.listProps.onScrollEndDrag(event(30, 1)); - }); - expect(view.current.away).toBe(true); - act(() => vi.advanceTimersByTime(MOMENTUM_WAIT_MS)); - expect(view.current.away).toBe(false); - view.unmount(); - vi.useRealTimers(); - }); - - it('waits for momentum to end once it begins', () => { - vi.useFakeTimers(); - const view = mount(); - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => { - view.current.listProps.onScrollEndDrag(event(300, 1)); - view.current.listProps.onMomentumScrollBegin(); - }); - act(() => vi.advanceTimersByTime(MOMENTUM_WAIT_MS * 4)); - expect(view.current.away).toBe(true); - act(() => view.current.listProps.onMomentumScrollEnd(event(10))); - expect(view.current.away).toBe(false); - view.unmount(); - vi.useRealTimers(); - }); - - it('settles a release just short of the end onto the latest message', () => { - const view = mount(); - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => view.current.listProps.onScrollEndDrag(event(30))); - expect(view.current.away).toBe(false); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); - view.unmount(); - }); -}); - -describe('following a growing conversation', () => { - it('uses scroll content geometry until the first content measurement, even after layout', () => { - const view = mount([message('latest', 'bot')]); - try { - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onScroll(event(0))); - act(() => view.current.jumpToLatest()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - act(() => view.current.listProps.onScroll(event(0, 0, CONTENT + 200))); - act(() => view.current.jumpToLatest()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); - } finally { - view.unmount(); - } - }); - - it.each([false, true])( - 'keeps measured growth with animateGrowth=%s after a stale scroll', - (animateGrowth) => { - const view = mount([message('a', 'bot')], 0, false, animateGrowth); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.scrollToOffset.mockClear(); - act(() => { - view.current.listProps.onContentSizeChange(0, CONTENT + 79); - view.current.listProps.onScroll(event(0)); - }); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ - offset: END + 79, - animated: animateGrowth, - }); - expect(view.current.away).toBe(false); - view.unmount(); - }, - ); - - it.each([ - { height: 600, offset: 1400 }, - { height: 900, offset: 1100 }, - ])( - 'pins a measured viewport height of $height despite an older scroll event', - ({ height, offset }) => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.scrollToOffset.mockClear(); - act(() => { - view.current.listProps.onLayout(layout(height)); - view.current.listProps.onScroll(event(0)); - }); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset, animated: false }); - view.unmount(); - }, - ); - - it('cancels a queued final-growth pin when the Human starts dragging', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.scrollToOffset.mockClear(); - act(() => { - view.current.listProps.onContentSizeChange(0, CONTENT + 79); - view.current.listProps.onScrollBeginDrag(); - view.current.listProps.onScrollEndDrag(event(200, 0, CONTENT + 79)); - }); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - expect(view.current.away).toBe(true); - act(() => view.current.jumpToLatest()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 79, animated: false }); - view.unmount(); - }); - - it('pins growth to the end at most once per frame without animation while following', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => { - view.current.listProps.onContentSizeChange(0, CONTENT); - view.current.listProps.onContentSizeChange(0, CONTENT + 20); - view.current.listProps.onContentSizeChange(0, CONTENT + 40); - }); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - flushFrames(); - expect(view.scrollToOffset.mock.calls).toEqual([[{ offset: END + 40, animated: false }]]); - view.unmount(); - }); - - it('never moves the viewport for growth after a drag past the slop', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - view.scrollToOffset.mockClear(); - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => view.current.listProps.onScroll(event(120))); - act(() => view.current.listProps.onScrollEndDrag(event(120))); - for (const grown of [CONTENT + 50, CONTENT + 100, CONTENT + 400]) { - act(() => view.current.listProps.onContentSizeChange(0, grown)); - flushFrames(); - } - expect(view.scrollToOffset).not.toHaveBeenCalled(); - expect(view.current.away).toBe(true); - view.unmount(); - }); - - it('counts unseen Bot messages while away and jumps back to the end', () => { - const first = [message('a', 'bot'), message('b', 'human')]; - const view = mount(first); - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => view.current.listProps.onScrollEndDrag(event(300))); - view.rerender([...first, message('c', 'bot'), message('d', 'system'), message('e', 'human')]); - expect(view.current.unseen).toBe(2); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 200)); - act(() => view.current.jumpToLatest()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 200, animated: false }); - expect(view.current.away).toBe(false); - expect(view.current.unseen).toBe(0); - view.unmount(); - }); - - it('does not count its own animated jump as leaving the latest message', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => view.current.listProps.onScrollEndDrag(event(600))); - act(() => view.current.jumpToLatest()); - act(() => { - view.current.listProps.onScroll(event(500)); - view.current.listProps.onScroll(event(200)); - }); - expect(view.current.away).toBe(false); - act(() => view.current.listProps.onMomentumScrollEnd(event(0))); - act(() => view.current.listProps.onScroll(event(200))); - expect(view.current.away).toBe(false); - act(() => view.current.listProps.onScrollBeginDrag()); - expect(view.current.away).toBe(true); - view.unmount(); - }); - - it('opens at the newest message when the list reports a scroll at the top before it is pinned', () => { - const view = mount([message('a', 'bot'), message('b', 'human')], 100); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onScroll(event(CONTENT - LAYOUT + 100))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT / 2)); - act(() => view.current.listProps.onScroll(event(CONTENT - LAYOUT, 0, CONTENT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); - expect(view.current.away).toBe(false); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.following).not.toHaveBeenCalled(); - view.unmount(); - }); - - it('leaves the latest message when the status bar scrolls the list to the top', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - view.scrollToOffset.mockClear(); - act(() => view.current.listProps.onScrollToTop()); - expect(view.current.away).toBe(true); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 100)); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - view.unmount(); - }); - - it.each([0, 100])( - 'waits for measured content after an early viewport layout (inset %s)', - (start) => { - const view = mount([message('a', 'bot')], start); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onContentSizeChange(0, 0)); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.unmount(); - }, - ); - - it('does not expose history until the native list acknowledges the requested bottom', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onScroll(event(300))); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.unmount(); - }); - - it('ignores an old native extent when newer content is still awaiting placement', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 300)); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onScroll(event(0, 0, CONTENT + 300))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.unmount(); - }); - - it('reveals the list only once the end has been pinned and the content settled', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - expect(view.current.ready).toBe(false); - act(() => frames.shift()!()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 300)); - act(() => frames.shift()!()); - expect(view.current.ready).toBe(false); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 300, animated: false }); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onScroll(event(0, 0, CONTENT + 300))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.unmount(); - }); - - it('stays hidden while rows keep rendering in batches a few frames apart', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - for (let frame = 0; frame < STABLE_FRAMES - 1; frame += 1) act(() => frames.shift()!()); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 400)); - for (let frame = 0; frame < STABLE_FRAMES - 1; frame += 1) act(() => frames.shift()!()); - expect(view.current.ready).toBe(false); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 400, animated: false }); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onScroll(event(0, 0, CONTENT + 400))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.unmount(); - }); - - it('loads older history only after the Human has dragged the list', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - expect(view.current.browsed).toBe(false); - act(() => view.current.listProps.onScrollBeginDrag()); - expect(view.current.browsed).toBe(true); - view.unmount(); - }); - - it('reveals rows that arrive after the list geometry has already settled', () => { - const view = mount([]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(false); - view.rerender([message('a', 'bot')]); - flushFrames(); - expect(view.current.ready).toBe(true); - view.unmount(); - }); - - it('offers retry when rows are present but the list never reports its content', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - for (let frame = 0; frame < CHECK_BUDGET * 2; frame += 1) - act(() => { - for (const callback of frames.splice(0)) callback(); - }); - expect(view.current.ready).toBe(false); - expect(view.current.canRetry).toBe(true); - view.unmount(); - }); - - it('pins the latest message even when a keyboard start never ends', () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const view = mount([message('a', 'bot')]); - act(() => view.current.keyboard.onStart()); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => vi.runAllTimers()); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.unmount(); - vi.useRealTimers(); - }); - - it('offers retry when the content height never stops changing', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - for (let frame = 0; frame < CHECK_BUDGET * 2; frame += 1) { - act(() => view.current.listProps.onContentSizeChange(0, frame % 2 === 0 ? 728 : 768)); - act(() => { - for (const callback of frames.splice(0)) callback(); - }); - } - expect(view.current.ready).toBe(false); - expect(view.current.canRetry).toBe(true); - view.unmount(); - }); - - it('offers retry rather than exposing unpositioned history after bounded checks', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - flushFrames(); - expect(view.current.ready).toBe(false); - expect(view.current.canRetry).toBe(true); - act(() => view.current.retry()); - expect(view.current.canRetry).toBe(false); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.current.canRetry).toBe(false); - view.unmount(); - }); - - it.each(['content', 'viewport'] as const)( - 'reveals history when the %s geometry settles after the initial retry appears', - (geometry) => { - const view = mount([message('pending', 'human')]); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - expect(view.current.ready).toBe(false); - expect(view.current.canRetry).toBe(true); - - act(() => { - if (geometry === 'content') view.current.listProps.onContentSizeChange(0, LAYOUT); - else view.current.listProps.onLayout(layout(CONTENT)); - }); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.current.canRetry).toBe(false); - } finally { - view.unmount(); - } - }, - ); - - it.each(['content', 'viewport'] as const)( - 'follows geometry reported by scroll before the %s callback', - (geometry) => { - const view = mount([message('latest', 'bot')]); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - const content = geometry === 'content' ? CONTENT + 200 : CONTENT; - const viewport = geometry === 'viewport' ? LAYOUT - 200 : LAYOUT; - view.scrollToOffset.mockClear(); - const reportGeometry = () => { - const scrollEvent = event(0); - view.current.listProps.onScroll({ - ...scrollEvent, - nativeEvent: { - ...scrollEvent.nativeEvent, - contentSize: { width: 440, height: content }, - layoutMeasurement: { width: 440, height: viewport }, - }, - }); - if (geometry === 'content') view.current.listProps.onContentSizeChange(0, content); - else view.current.listProps.onLayout(layout(viewport)); - }; - act(reportGeometry); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ - offset: END + 200, - animated: false, - }); - act(() => view.current.hold()); - view.scrollToOffset.mockClear(); - act(reportGeometry); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - expect(view.current.away).toBe(true); - } finally { - view.unmount(); - } - }, - ); - - it('keeps retry available when repeated geometry events report unchanged dimensions', () => { - const view = mount([message('pending', 'human')]); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - expect(view.current.canRetry).toBe(true); - for (let repeat = 0; repeat < 3; repeat += 1) { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - expect(view.current.canRetry).toBe(true); - flushFrames(); - expect(view.current.ready).toBe(false); - expect(view.current.canRetry).toBe(true); - } - } finally { - view.unmount(); - } - }); - - it.each(['content', 'viewport'] as const)( - 'requires fresh stable frames after the %s geometry changes during confirmation', - (geometry) => { - const view = mount([message('pending', 'human')]); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, LAYOUT); - }); - for (let frame = 0; frame < STABLE_FRAMES; frame += 1) - act(() => { - for (const callback of frames.splice(0)) callback(); - }); - expect(view.current.ready).toBe(false); - act(() => { - if (geometry === 'content') view.current.listProps.onContentSizeChange(0, LAYOUT - 100); - else view.current.listProps.onLayout(layout(LAYOUT + 100)); - }); - act(() => { - for (const callback of frames.splice(0)) callback(); - }); - expect(view.current.ready).toBe(false); - flushFrames(); - expect(view.current.ready).toBe(true); - } finally { - view.unmount(); - } - }, - ); - - it('keeps a held viewport visible without scrolling when late geometry arrives after retry', () => { - const view = mount([message('pending', 'human')]); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - expect(view.current.canRetry).toBe(true); - act(() => view.current.hold()); - view.scrollToOffset.mockClear(); - act(() => { - view.current.listProps.onLayout(layout(LAYOUT + 100)); - view.current.listProps.onContentSizeChange(0, CONTENT + 100); - }); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.current.canRetry).toBe(false); - expect(view.current.away).toBe(true); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - } finally { - view.unmount(); - } - }); - - it('reveals short content already at the initial offset without a no-op scroll event', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, LAYOUT)); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.current.canRetry).toBe(false); - view.unmount(); - }); - - it('reveals fitting Android content including its hairline without a no-op scroll event', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, LAYOUT + 0.5)); - flushFrames(); - expect(view.current.ready).toBe(true); - view.unmount(); - }); - - it('lets an explicit message-focus jump reveal history without waiting for the bottom', () => { - const view = mount([message('a', 'bot')]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - act(() => view.current.hold()); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.current.away).toBe(true); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - view.unmount(); - }); - - it('keeps a short conversation under the header instead of scrolling past its start', () => { - const view = mount([message('a', 'bot')], 100); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, LAYOUT - 100)); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: -100, animated: false }); - expect(view.current.ready).toBe(true); - act(() => view.current.listProps.onContentSizeChange(0, LAYOUT - 300)); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: -100, animated: false }); - view.unmount(); - }); - - it('keeps an empty conversation shown when its first message arrives', () => { - const view = mount([], 0, true); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, LAYOUT)); - flushFrames(); - expect(view.current.ready).toBe(true); - view.rerender([message('a', 'human')]); - expect(view.current.ready).toBe(true); - view.unmount(); - }); - - it('shows the list when the Human sends before the conversation has settled', () => { - const view = mount([]); - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - act(() => view.current.listProps.onContentSizeChange(0, LAYOUT)); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => view.current.jumpToLatest()); - view.rerender([message('a', 'human')]); - expect(view.current.ready).toBe(true); - view.unmount(); - }); - - it('keeps the native anchor configured across follow and history transitions', () => { - const view = mount([message('a', 'bot')]); - expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ - minIndexForVisible: 0, - }); - act(() => view.current.listProps.onScrollBeginDrag()); - expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ - minIndexForVisible: 0, - }); - act(() => view.current.jumpToLatest()); - expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ - minIndexForVisible: 0, - }); - view.unmount(); - }); -}); - -describe('native keyboard and reading-position coordination', () => { - it('leaves scrolling to the keyboard during motion, then follows newly streamed content', () => { - const view = mount([message('latest', 'bot')]); - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - view.current.keyboard.onStart(); - view.current.keyboard.onInsetChange({ bottom: 300 }); - view.current.listProps.onContentSizeChange(0, CONTENT + 100); - }); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => view.current.keyboard.onEnd()); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 1600, animated: false }); - view.unmount(); - }); - - it('keeps a held history position through keyboard and streamed-content changes', () => { - const view = mount([message('latest', 'bot')]); - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - view.current.hold(); - view.current.keyboard.onStart(); - view.current.keyboard.onInsetChange({ bottom: 300 }); - view.current.listProps.onContentSizeChange(0, CONTENT + 100); - view.current.keyboard.onEnd(); - }); - flushFrames(); - expect(view.current.away).toBe(true); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => view.current.jumpToLatest()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 1600, animated: false }); - view.unmount(); - }); -}); - -it('animates settled streaming growth without unchanged callbacks snapping the animation', () => { - const view = mount([message('latest', 'bot')], 0, false, true); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - view.scrollToOffset.mockClear(); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 60, animated: true }); - view.scrollToOffset.mockClear(); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => view.current.hold()); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 120)); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - } finally { - view.unmount(); - } -}); - -describe('sending a message', () => { - function settled(animateScroll: boolean, nativeFollow = false) { - const view = mount([message('latest', 'bot')], 0, false, animateScroll, false, nativeFollow); - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.scrollToOffset.mockClear(); - return view; - } - - it('requests a fly at the newest message and lets the native anchor glide the growth', () => { - const view = settled(true, true); - try { - let fly = false; - act(() => { - fly = view.current.send(); - }); - expect(fly).toBe(true); - view.rerender([message('latest', 'bot'), message('sent', 'human')]); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - expect(view.current.away).toBe(false); - } finally { - view.unmount(); - } - }); - - it('finishes the glide when the native anchor stops short of the newest message', () => { - const view = settled(true, true); - vi.useFakeTimers(); - try { - act(() => { - view.current.send(); - }); - view.rerender([message('latest', 'bot'), message('sent', 'human')]); - act(() => { - view.current.listProps.onContentSizeChange(0, CONTENT + 60); - view.current.listProps.onScroll(event(48, 0, CONTENT + 60)); - }); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => vi.advanceTimersByTime(NATIVE_GLIDE_MS)); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ - offset: CONTENT + 60 - LAYOUT, - animated: true, - }); - } finally { - view.unmount(); - vi.useRealTimers(); - } - }); - - it('requests no fly when Reduce Motion is on but still follows the new message', () => { - const view = settled(false); - try { - let fly = true; - act(() => { - fly = view.current.send(); - }); - expect(fly).toBe(false); - view.rerender([message('latest', 'bot'), message('sent', 'human')]); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 60, animated: false }); - } finally { - view.unmount(); - } - }); - - it('jumps to the newest message on send from history when the person asked for it', () => { - const view = settled(true); - try { - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => view.current.listProps.onScrollEndDrag(event(300))); - view.scrollToOffset.mockClear(); - let fly = true; - act(() => { - fly = view.current.send(true); - }); - expect(fly).toBe(false); - expect(view.current.away).toBe(false); - expect(view.scrollToOffset).toHaveBeenCalled(); - } finally { - view.unmount(); - } - }); - - it('requests no fly and leaves the scroll offset unchanged while reading history', () => { - const view = settled(true); - try { - act(() => view.current.listProps.onScrollBeginDrag()); - act(() => view.current.listProps.onScrollEndDrag(event(300))); - view.scrollToOffset.mockClear(); - let fly = true; - act(() => { - fly = view.current.send(); - }); - expect(fly).toBe(false); - view.rerender([message('latest', 'bot'), message('sent', 'human')]); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - expect(view.current.away).toBe(true); - } finally { - view.unmount(); - } - }); -}); - -it('leaves settled growth to the native latest anchor but still pins the first placement', () => { - const view = mount([message('latest', 'bot')], 0, false, true, false, true); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenCalledWith({ offset: END, animated: false }); - act(() => view.current.listProps.onScroll(event(0))); - flushFrames(); - expect(view.current.ready).toBe(true); - view.scrollToOffset.mockClear(); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => view.current.jumpToLatest()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 60, animated: true }); - } finally { - view.unmount(); - } -}); - -it('retains Android mVCP while native following owns growth and JS owns explicit jumps', () => { - const view = mount([message('latest', 'bot')], 0, false, true, true, true); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); - expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ - minIndexForVisible: 0, - }); - view.scrollToOffset.mockClear(); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => view.current.listProps.onScrollBeginDrag()); - expect(view.current.away).toBe(true); - expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ - minIndexForVisible: 0, - }); - act(() => view.current.jumpToLatest()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: true }); - expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ - minIndexForVisible: 0, - }); - } finally { - view.unmount(); - } -}); - -it('keeps native keyboard ownership even when streaming growth is animated', () => { - const view = mount([message('latest', 'bot')], 0, false, true); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - view.current.jumpToLatest(); - }); - flushFrames(); - view.scrollToOffset.mockClear(); - act(() => { - view.current.keyboard.onStart(); - view.current.listProps.onContentSizeChange(0, CONTENT + 60); - }); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => view.current.keyboard.onEnd()); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 60, animated: false }); - } finally { - view.unmount(); - } -}); - -describe('latest-first Android list', () => { - it('reveals measured latest-first content after positioning without waiting for older-row sizes', () => { - const view = mount([message('latest', 'bot')], 0, false, true, true); - try { - act(() => view.current.listProps.onLayout(layout(LAYOUT))); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); - act(() => { - for (const callback of frames.splice(0)) callback(); - }); - expect(view.current.ready).toBe(false); - act(() => { - for (const callback of frames.splice(0)) callback(); - }); - expect(view.current.ready).toBe(true); - expect(view.current.canRetry).toBe(false); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); - act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 900)); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: true }); - } finally { - view.unmount(); - } - }); - - it('does not reveal an inverted viewport away from zero and recovers after retry', () => { - const view = mount([message('latest', 'bot')], 0, false, true, true); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - view.current.listProps.onScroll(event(END - 180)); - }); - flushFrames(); - expect(view.current.ready).toBe(false); - expect(view.current.canRetry).toBe(true); - act(() => { - view.current.retry(); - view.current.listProps.onScroll(event(END)); - }); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.current.canRetry).toBe(false); - } finally { - view.unmount(); - } - }); - - it('waits for keyboard handoff before revealing an initial inverted viewport', () => { - const view = mount([message('latest', 'bot')], 0, false, true, true); - try { - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - act(() => { - for (const callback of frames.splice(0)) callback(); - }); - act(() => view.current.keyboard.onStart()); - flushFrames(); - expect(view.current.ready).toBe(false); - act(() => { - view.current.keyboard.onInsetChange({ bottom: 300 }); - view.current.listProps.onLayout(layout(LAYOUT - 300)); - view.current.keyboard.onEnd(); - }); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); - } finally { - view.unmount(); - } - }); - - it('positions at zero without measuring all older rows', () => { - const view = mount([message('latest', 'bot')], 0, false, true, true); - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - expect(view.current.ready).toBe(true); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); - view.unmount(); - }); - it('holds reading while content grows and explicitly returns to zero', () => { - const view = mount([message('latest', 'bot')], 0, false, true, true); - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - view.scrollToOffset.mockClear(); - act(() => { - view.current.listProps.onScrollBeginDrag(); - view.current.listProps.onScrollEndDrag(event(END - 180)); - view.current.listProps.onContentSizeChange(0, CONTENT + 300); - }); - flushFrames(); - expect(view.current.away).toBe(true); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => view.current.jumpToLatest()); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: true }); - view.unmount(); - }); - it('keeps latest at zero after keyboard handoff', () => { - const view = mount([message('latest', 'bot')], 0, false, true, true); - act(() => { - view.current.listProps.onLayout(layout(LAYOUT)); - view.current.listProps.onContentSizeChange(0, CONTENT); - }); - flushFrames(); - view.scrollToOffset.mockClear(); - act(() => { - view.current.keyboard.onStart(); - view.current.keyboard.onInsetChange({ bottom: 300 }); - view.current.listProps.onContentSizeChange(0, CONTENT + 400); - }); - flushFrames(); - expect(view.scrollToOffset).not.toHaveBeenCalled(); - act(() => view.current.keyboard.onEnd()); - flushFrames(); - expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); - view.unmount(); - }); -}); +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import type { + FlatList, + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, +} from 'react-native'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ConversationMessage } from '../src/host/conversation'; +import { + CHECK_BUDGET, + MOMENTUM_WAIT_MS, + NATIVE_GLIDE_MS, + STABLE_FRAMES, + useFollowLatest, +} from '../src/ui/chat/follow-latest'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const CONTENT = 2000; +const LAYOUT = 800; +const END = CONTENT - LAYOUT; + +const event = (distance: number, velocity = 0, content = CONTENT) => + ({ + nativeEvent: { + contentOffset: { y: content - LAYOUT - distance }, + contentSize: { height: content }, + layoutMeasurement: { height: LAYOUT }, + velocity: { y: velocity }, + }, + }) as NativeSyntheticEvent; + +const layout = (height: number) => ({ nativeEvent: { layout: { height } } }) as LayoutChangeEvent; + +let frames: Array<() => void> = []; +const flushFrames = () => + act(() => { + for (let round = 0; round < 64 && frames.length > 0; round += 1) + for (const callback of frames.splice(0)) callback(); + }); + +beforeEach(() => { + frames = []; + let nextFrame = 0; + const cancelled = new Set(); + vi.stubGlobal('requestAnimationFrame', (callback: () => void) => { + const id = ++nextFrame; + frames.push(() => { + if (!cancelled.has(id)) callback(); + }); + return id; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => cancelled.add(id)); +}); +afterEach(() => vi.unstubAllGlobals()); + +function message(id: string, author: ConversationMessage['author']): ConversationMessage { + return { id, at: '', author, body: id, attachments: [], delivery: 'committed' }; +} + +function mount( + messages: readonly ConversationMessage[] = [], + start = 0, + empty = false, + animateScroll = false, + inverted = false, + nativeFollow = false, +) { + const following = vi.fn(); + const scrollToOffset = vi.fn(); + const list = { current: { scrollToOffset } as unknown as FlatList }; + const probe: { current?: ReturnType> } = {}; + function Probe({ rows, known }: { rows: readonly ConversationMessage[]; known: boolean }) { + probe.current = useFollowLatest( + list, + rows, + following, + start, + known && rows.length === 0, + animateScroll, + 0, + inverted, + nativeFollow, + ); + return null; + } + const root = createRoot(document.createElement('div')); + act(() => root.render()); + return { + following, + scrollToOffset, + get current() { + return probe.current!; + }, + rerender: (rows: readonly ConversationMessage[]) => + act(() => root.render()), + unmount: () => act(() => root.unmount()), + }; +} + +describe('history-reading gesture ownership', () => { + it('does not let programmatic momentum resume following after an explicit message focus', () => { + const view = mount([message('latest', 'bot')]); + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + view.current.hold(); + view.current.listProps.onMomentumScrollEnd(event(0)); + view.current.listProps.onContentSizeChange(0, CONTENT + 300); + }); + flushFrames(); + expect(view.current.away).toBe(true); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + view.unmount(); + }); + + it('keeps a replacement playback controller held when display mode changes in history', () => { + const first = vi.fn(); + const replacement = vi.fn(); + const list = { current: null }; + let current!: ReturnType>; + function Probe({ onChange }: { onChange: (following: boolean) => void }) { + current = useFollowLatest(list, [], onChange); + return null; + } + const root = createRoot(document.createElement('div')); + act(() => root.render()); + act(() => current.listProps.onScrollBeginDrag()); + act(() => current.listProps.onScrollEndDrag(event(180))); + act(() => root.render()); + expect(replacement).toHaveBeenCalledWith(false); + expect(current.away).toBe(true); + act(() => root.unmount()); + }); + + it('holds immediately and ignores a near-bottom layout event until a return gesture ends', () => { + const view = mount(); + act(() => view.current.listProps.onScrollBeginDrag()); + expect(view.following.mock.calls).toEqual([[false]]); + expect(view.current.away).toBe(true); + act(() => { + view.current.listProps.onScroll(event(180)); + view.current.listProps.onScrollEndDrag(event(180, 1)); + view.current.listProps.onScroll(event(0)); + view.current.listProps.onMomentumScrollEnd(event(180)); + }); + expect(view.following.mock.calls).toEqual([[false]]); + expect(view.current.away).toBe(true); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => { + view.current.listProps.onScrollBeginDrag(); + view.current.listProps.onScroll(event(0)); + view.current.listProps.onScrollEndDrag(event(0)); + }); + expect(view.following.mock.calls).toEqual([[false], [true]]); + expect(view.current.away).toBe(false); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.jumpToLatest()); + expect(view.following.mock.calls).toEqual([[false], [true], [false], [true]]); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); + view.unmount(); + }); + + it('settles a flick release when no momentum follows', () => { + vi.useFakeTimers(); + const view = mount(); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => { + view.current.listProps.onScroll(event(30)); + view.current.listProps.onScrollEndDrag(event(30, 1)); + }); + expect(view.current.away).toBe(true); + act(() => vi.advanceTimersByTime(MOMENTUM_WAIT_MS)); + expect(view.current.away).toBe(false); + view.unmount(); + vi.useRealTimers(); + }); + + it('waits for momentum to end once it begins', () => { + vi.useFakeTimers(); + const view = mount(); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => { + view.current.listProps.onScrollEndDrag(event(300, 1)); + view.current.listProps.onMomentumScrollBegin(); + }); + act(() => vi.advanceTimersByTime(MOMENTUM_WAIT_MS * 4)); + expect(view.current.away).toBe(true); + act(() => view.current.listProps.onMomentumScrollEnd(event(10))); + expect(view.current.away).toBe(false); + view.unmount(); + vi.useRealTimers(); + }); + + it('settles a release just short of the end onto the latest message', () => { + const view = mount(); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.listProps.onScrollEndDrag(event(30))); + expect(view.current.away).toBe(false); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); + view.unmount(); + }); +}); + +describe('following a growing conversation', () => { + it('uses scroll content geometry until the first content measurement, even after layout', () => { + const view = mount([message('latest', 'bot')]); + try { + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onScroll(event(0))); + act(() => view.current.jumpToLatest()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + act(() => view.current.listProps.onScroll(event(0, 0, CONTENT + 200))); + act(() => view.current.jumpToLatest()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); + } finally { + view.unmount(); + } + }); + + it.each([false, true])( + 'keeps measured growth with animateGrowth=%s after a stale scroll', + (animateGrowth) => { + const view = mount([message('a', 'bot')], 0, false, animateGrowth); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.scrollToOffset.mockClear(); + act(() => { + view.current.listProps.onContentSizeChange(0, CONTENT + 79); + view.current.listProps.onScroll(event(0)); + }); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ + offset: END + 79, + animated: animateGrowth, + }); + expect(view.current.away).toBe(false); + view.unmount(); + }, + ); + + it.each([ + { height: 600, offset: 1400 }, + { height: 900, offset: 1100 }, + ])( + 'pins a measured viewport height of $height despite an older scroll event', + ({ height, offset }) => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.scrollToOffset.mockClear(); + act(() => { + view.current.listProps.onLayout(layout(height)); + view.current.listProps.onScroll(event(0)); + }); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset, animated: false }); + view.unmount(); + }, + ); + + it('cancels a queued final-growth pin when the Human starts dragging', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.scrollToOffset.mockClear(); + act(() => { + view.current.listProps.onContentSizeChange(0, CONTENT + 79); + view.current.listProps.onScrollBeginDrag(); + view.current.listProps.onScrollEndDrag(event(200, 0, CONTENT + 79)); + }); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + expect(view.current.away).toBe(true); + act(() => view.current.jumpToLatest()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 79, animated: false }); + view.unmount(); + }); + + it('pins growth to the end at most once per frame without animation while following', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => { + view.current.listProps.onContentSizeChange(0, CONTENT); + view.current.listProps.onContentSizeChange(0, CONTENT + 20); + view.current.listProps.onContentSizeChange(0, CONTENT + 40); + }); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + flushFrames(); + expect(view.scrollToOffset.mock.calls).toEqual([[{ offset: END + 40, animated: false }]]); + view.unmount(); + }); + + it('never moves the viewport for growth after a drag past the slop', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + view.scrollToOffset.mockClear(); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.listProps.onScroll(event(120))); + act(() => view.current.listProps.onScrollEndDrag(event(120))); + for (const grown of [CONTENT + 50, CONTENT + 100, CONTENT + 400]) { + act(() => view.current.listProps.onContentSizeChange(0, grown)); + flushFrames(); + } + expect(view.scrollToOffset).not.toHaveBeenCalled(); + expect(view.current.away).toBe(true); + view.unmount(); + }); + + it('counts unseen Bot messages while away and jumps back to the end', () => { + const first = [message('a', 'bot'), message('b', 'human')]; + const view = mount(first); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.listProps.onScrollEndDrag(event(300))); + view.rerender([...first, message('c', 'bot'), message('d', 'system'), message('e', 'human')]); + expect(view.current.unseen).toBe(2); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 200)); + act(() => view.current.jumpToLatest()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 200, animated: false }); + expect(view.current.away).toBe(false); + expect(view.current.unseen).toBe(0); + view.unmount(); + }); + + it('does not count its own animated jump as leaving the latest message', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.listProps.onScrollEndDrag(event(600))); + act(() => view.current.jumpToLatest()); + act(() => { + view.current.listProps.onScroll(event(500)); + view.current.listProps.onScroll(event(200)); + }); + expect(view.current.away).toBe(false); + act(() => view.current.listProps.onMomentumScrollEnd(event(0))); + act(() => view.current.listProps.onScroll(event(200))); + expect(view.current.away).toBe(false); + act(() => view.current.listProps.onScrollBeginDrag()); + expect(view.current.away).toBe(true); + view.unmount(); + }); + + it('opens at the newest message when the list reports a scroll at the top before it is pinned', () => { + const view = mount([message('a', 'bot'), message('b', 'human')], 100); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onScroll(event(CONTENT - LAYOUT + 100))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT / 2)); + act(() => view.current.listProps.onScroll(event(CONTENT - LAYOUT, 0, CONTENT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); + expect(view.current.away).toBe(false); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.following).not.toHaveBeenCalled(); + view.unmount(); + }); + + it('leaves the latest message when the status bar scrolls the list to the top', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + view.scrollToOffset.mockClear(); + act(() => view.current.listProps.onScrollToTop()); + expect(view.current.away).toBe(true); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 100)); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + view.unmount(); + }); + + it.each([0, 100])( + 'waits for measured content after an early viewport layout (inset %s)', + (start) => { + const view = mount([message('a', 'bot')], start); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onContentSizeChange(0, 0)); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.unmount(); + }, + ); + + it('does not expose history until the native list acknowledges the requested bottom', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onScroll(event(300))); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('ignores an old native extent when newer content is still awaiting placement', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 300)); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onScroll(event(0, 0, CONTENT + 300))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('reveals the list only once the end has been pinned and the content settled', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + expect(view.current.ready).toBe(false); + act(() => frames.shift()!()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 300)); + act(() => frames.shift()!()); + expect(view.current.ready).toBe(false); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 300, animated: false }); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onScroll(event(0, 0, CONTENT + 300))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('stays hidden while rows keep rendering in batches a few frames apart', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + for (let frame = 0; frame < STABLE_FRAMES - 1; frame += 1) act(() => frames.shift()!()); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 400)); + for (let frame = 0; frame < STABLE_FRAMES - 1; frame += 1) act(() => frames.shift()!()); + expect(view.current.ready).toBe(false); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 400, animated: false }); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onScroll(event(0, 0, CONTENT + 400))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('loads older history only after the Human has dragged the list', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + expect(view.current.browsed).toBe(false); + act(() => view.current.listProps.onScrollBeginDrag()); + expect(view.current.browsed).toBe(true); + view.unmount(); + }); + + it('reveals rows that arrive after the list geometry has already settled', () => { + const view = mount([]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(false); + view.rerender([message('a', 'bot')]); + flushFrames(); + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('offers retry when rows are present but the list never reports its content', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + for (let frame = 0; frame < CHECK_BUDGET * 2; frame += 1) + act(() => { + for (const callback of frames.splice(0)) callback(); + }); + expect(view.current.ready).toBe(false); + expect(view.current.canRetry).toBe(true); + view.unmount(); + }); + + it('pins the latest message even when a keyboard start never ends', () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const view = mount([message('a', 'bot')]); + act(() => view.current.keyboard.onStart()); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => vi.runAllTimers()); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END, animated: false }); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.unmount(); + vi.useRealTimers(); + }); + + it('offers retry when the content height never stops changing', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + for (let frame = 0; frame < CHECK_BUDGET * 2; frame += 1) { + act(() => view.current.listProps.onContentSizeChange(0, frame % 2 === 0 ? 728 : 768)); + act(() => { + for (const callback of frames.splice(0)) callback(); + }); + } + expect(view.current.ready).toBe(false); + expect(view.current.canRetry).toBe(true); + view.unmount(); + }); + + it('offers retry rather than exposing unpositioned history after bounded checks', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + flushFrames(); + expect(view.current.ready).toBe(false); + expect(view.current.canRetry).toBe(true); + act(() => view.current.retry()); + expect(view.current.canRetry).toBe(false); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.current.canRetry).toBe(false); + view.unmount(); + }); + + it.each(['content', 'viewport'] as const)( + 'reveals history when the %s geometry settles after the initial retry appears', + (geometry) => { + const view = mount([message('pending', 'human')]); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + expect(view.current.ready).toBe(false); + expect(view.current.canRetry).toBe(true); + + act(() => { + if (geometry === 'content') view.current.listProps.onContentSizeChange(0, LAYOUT); + else view.current.listProps.onLayout(layout(CONTENT)); + }); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.current.canRetry).toBe(false); + } finally { + view.unmount(); + } + }, + ); + + it.each(['content', 'viewport'] as const)( + 'follows geometry reported by scroll before the %s callback', + (geometry) => { + const view = mount([message('latest', 'bot')]); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + const content = geometry === 'content' ? CONTENT + 200 : CONTENT; + const viewport = geometry === 'viewport' ? LAYOUT - 200 : LAYOUT; + view.scrollToOffset.mockClear(); + const reportGeometry = () => { + const scrollEvent = event(0); + view.current.listProps.onScroll({ + ...scrollEvent, + nativeEvent: { + ...scrollEvent.nativeEvent, + contentSize: { width: 440, height: content }, + layoutMeasurement: { width: 440, height: viewport }, + }, + }); + if (geometry === 'content') view.current.listProps.onContentSizeChange(0, content); + else view.current.listProps.onLayout(layout(viewport)); + }; + act(reportGeometry); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ + offset: END + 200, + animated: false, + }); + act(() => view.current.hold()); + view.scrollToOffset.mockClear(); + act(reportGeometry); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + expect(view.current.away).toBe(true); + } finally { + view.unmount(); + } + }, + ); + + it('keeps retry available when repeated geometry events report unchanged dimensions', () => { + const view = mount([message('pending', 'human')]); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + expect(view.current.canRetry).toBe(true); + for (let repeat = 0; repeat < 3; repeat += 1) { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + expect(view.current.canRetry).toBe(true); + flushFrames(); + expect(view.current.ready).toBe(false); + expect(view.current.canRetry).toBe(true); + } + } finally { + view.unmount(); + } + }); + + it.each(['content', 'viewport'] as const)( + 'requires fresh stable frames after the %s geometry changes during confirmation', + (geometry) => { + const view = mount([message('pending', 'human')]); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, LAYOUT); + }); + for (let frame = 0; frame < STABLE_FRAMES; frame += 1) + act(() => { + for (const callback of frames.splice(0)) callback(); + }); + expect(view.current.ready).toBe(false); + act(() => { + if (geometry === 'content') view.current.listProps.onContentSizeChange(0, LAYOUT - 100); + else view.current.listProps.onLayout(layout(LAYOUT + 100)); + }); + act(() => { + for (const callback of frames.splice(0)) callback(); + }); + expect(view.current.ready).toBe(false); + flushFrames(); + expect(view.current.ready).toBe(true); + } finally { + view.unmount(); + } + }, + ); + + it('keeps a held viewport visible without scrolling when late geometry arrives after retry', () => { + const view = mount([message('pending', 'human')]); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + expect(view.current.canRetry).toBe(true); + act(() => view.current.hold()); + view.scrollToOffset.mockClear(); + act(() => { + view.current.listProps.onLayout(layout(LAYOUT + 100)); + view.current.listProps.onContentSizeChange(0, CONTENT + 100); + }); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.current.canRetry).toBe(false); + expect(view.current.away).toBe(true); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + } finally { + view.unmount(); + } + }); + + it('reveals short content already at the initial offset without a no-op scroll event', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, LAYOUT)); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.current.canRetry).toBe(false); + view.unmount(); + }); + + it('reveals fitting Android content including its hairline without a no-op scroll event', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, LAYOUT + 0.5)); + flushFrames(); + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('lets an explicit message-focus jump reveal history without waiting for the bottom', () => { + const view = mount([message('a', 'bot')]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + act(() => view.current.hold()); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.current.away).toBe(true); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + view.unmount(); + }); + + it('keeps a short conversation under the header instead of scrolling past its start', () => { + const view = mount([message('a', 'bot')], 100); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, LAYOUT - 100)); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: -100, animated: false }); + expect(view.current.ready).toBe(true); + act(() => view.current.listProps.onContentSizeChange(0, LAYOUT - 300)); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: -100, animated: false }); + view.unmount(); + }); + + it('keeps an empty conversation shown when its first message arrives', () => { + const view = mount([], 0, true); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, LAYOUT)); + flushFrames(); + expect(view.current.ready).toBe(true); + view.rerender([message('a', 'human')]); + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('shows the list when the Human sends before the conversation has settled', () => { + const view = mount([]); + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + act(() => view.current.listProps.onContentSizeChange(0, LAYOUT)); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => view.current.jumpToLatest()); + view.rerender([message('a', 'human')]); + expect(view.current.ready).toBe(true); + view.unmount(); + }); + + it('keeps the native anchor configured across follow and history transitions', () => { + const view = mount([message('a', 'bot')]); + expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ + minIndexForVisible: 0, + }); + act(() => view.current.listProps.onScrollBeginDrag()); + expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ + minIndexForVisible: 0, + }); + act(() => view.current.jumpToLatest()); + expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ + minIndexForVisible: 0, + }); + view.unmount(); + }); +}); + +describe('native keyboard and reading-position coordination', () => { + it('leaves scrolling to the keyboard during motion, then follows newly streamed content', () => { + const view = mount([message('latest', 'bot')]); + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + view.current.keyboard.onStart(); + view.current.keyboard.onInsetChange({ bottom: 300 }); + view.current.listProps.onContentSizeChange(0, CONTENT + 100); + }); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => view.current.keyboard.onEnd()); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 1600, animated: false }); + view.unmount(); + }); + + it('keeps a held history position through keyboard and streamed-content changes', () => { + const view = mount([message('latest', 'bot')]); + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + view.current.hold(); + view.current.keyboard.onStart(); + view.current.keyboard.onInsetChange({ bottom: 300 }); + view.current.listProps.onContentSizeChange(0, CONTENT + 100); + view.current.keyboard.onEnd(); + }); + flushFrames(); + expect(view.current.away).toBe(true); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => view.current.jumpToLatest()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 1600, animated: false }); + view.unmount(); + }); +}); + +it('animates settled streaming growth without unchanged callbacks snapping the animation', () => { + const view = mount([message('latest', 'bot')], 0, false, true); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + view.scrollToOffset.mockClear(); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 60, animated: true }); + view.scrollToOffset.mockClear(); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => view.current.hold()); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 120)); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + } finally { + view.unmount(); + } +}); + +describe('sending a message', () => { + function settled(animateScroll: boolean, nativeFollow = false) { + const view = mount([message('latest', 'bot')], 0, false, animateScroll, false, nativeFollow); + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.scrollToOffset.mockClear(); + return view; + } + + it('requests a fly at the newest message and lets the native anchor glide the growth', () => { + const view = settled(true, true); + try { + let fly = false; + act(() => { + fly = view.current.send(); + }); + expect(fly).toBe(true); + view.rerender([message('latest', 'bot'), message('sent', 'human')]); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + expect(view.current.away).toBe(false); + } finally { + view.unmount(); + } + }); + + it('finishes the glide when the native anchor stops short of the newest message', () => { + const view = settled(true, true); + vi.useFakeTimers(); + try { + act(() => { + view.current.send(); + }); + view.rerender([message('latest', 'bot'), message('sent', 'human')]); + act(() => { + view.current.listProps.onContentSizeChange(0, CONTENT + 60); + view.current.listProps.onScroll(event(48, 0, CONTENT + 60)); + }); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(NATIVE_GLIDE_MS)); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ + offset: CONTENT + 60 - LAYOUT, + animated: true, + }); + } finally { + view.unmount(); + vi.useRealTimers(); + } + }); + + it('returns to the newest message when a send leaves the content shorter and the list shifted', () => { + const view = settled(true, true); + vi.useFakeTimers(); + try { + act(() => { + view.current.send(); + }); + view.rerender([message('latest', 'bot'), message('sent', 'human')]); + act(() => { + view.current.listProps.onContentSizeChange(0, CONTENT - 20); + view.current.listProps.onScroll(event(42, 0, CONTENT - 20)); + }); + act(() => vi.advanceTimersByTime(NATIVE_GLIDE_MS)); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ + offset: CONTENT - 20 - LAYOUT, + animated: true, + }); + } finally { + view.unmount(); + vi.useRealTimers(); + } + }); + + it('requests no fly when Reduce Motion is on but still follows the new message', () => { + const view = settled(false); + try { + let fly = true; + act(() => { + fly = view.current.send(); + }); + expect(fly).toBe(false); + view.rerender([message('latest', 'bot'), message('sent', 'human')]); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 60, animated: false }); + } finally { + view.unmount(); + } + }); + + it('jumps to the newest message on send from history when the person asked for it', () => { + const view = settled(true); + try { + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.listProps.onScrollEndDrag(event(300))); + view.scrollToOffset.mockClear(); + let fly = true; + act(() => { + fly = view.current.send(true); + }); + expect(fly).toBe(false); + expect(view.current.away).toBe(false); + expect(view.scrollToOffset).toHaveBeenCalled(); + } finally { + view.unmount(); + } + }); + + it('requests no fly and leaves the scroll offset unchanged while reading history', () => { + const view = settled(true); + try { + act(() => view.current.listProps.onScrollBeginDrag()); + act(() => view.current.listProps.onScrollEndDrag(event(300))); + view.scrollToOffset.mockClear(); + let fly = true; + act(() => { + fly = view.current.send(); + }); + expect(fly).toBe(false); + view.rerender([message('latest', 'bot'), message('sent', 'human')]); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + expect(view.current.away).toBe(true); + } finally { + view.unmount(); + } + }); +}); + +it('leaves settled growth to the native latest anchor but still pins the first placement', () => { + const view = mount([message('latest', 'bot')], 0, false, true, false, true); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenCalledWith({ offset: END, animated: false }); + act(() => view.current.listProps.onScroll(event(0))); + flushFrames(); + expect(view.current.ready).toBe(true); + view.scrollToOffset.mockClear(); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => view.current.jumpToLatest()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 60, animated: true }); + } finally { + view.unmount(); + } +}); + +it('retains Android mVCP while native following owns growth and JS owns explicit jumps', () => { + const view = mount([message('latest', 'bot')], 0, false, true, true, true); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); + expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ + minIndexForVisible: 0, + }); + view.scrollToOffset.mockClear(); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 60)); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => view.current.listProps.onScrollBeginDrag()); + expect(view.current.away).toBe(true); + expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ + minIndexForVisible: 0, + }); + act(() => view.current.jumpToLatest()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: true }); + expect(view.current.listProps.maintainVisibleContentPosition).toEqual({ + minIndexForVisible: 0, + }); + } finally { + view.unmount(); + } +}); + +it('keeps native keyboard ownership even when streaming growth is animated', () => { + const view = mount([message('latest', 'bot')], 0, false, true); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + view.current.jumpToLatest(); + }); + flushFrames(); + view.scrollToOffset.mockClear(); + act(() => { + view.current.keyboard.onStart(); + view.current.listProps.onContentSizeChange(0, CONTENT + 60); + }); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => view.current.keyboard.onEnd()); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: END + 60, animated: false }); + } finally { + view.unmount(); + } +}); + +describe('latest-first Android list', () => { + it('reveals measured latest-first content after positioning without waiting for older-row sizes', () => { + const view = mount([message('latest', 'bot')], 0, false, true, true); + try { + act(() => view.current.listProps.onLayout(layout(LAYOUT))); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT)); + act(() => { + for (const callback of frames.splice(0)) callback(); + }); + expect(view.current.ready).toBe(false); + act(() => { + for (const callback of frames.splice(0)) callback(); + }); + expect(view.current.ready).toBe(true); + expect(view.current.canRetry).toBe(false); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); + act(() => view.current.listProps.onContentSizeChange(0, CONTENT + 900)); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: true }); + } finally { + view.unmount(); + } + }); + + it('does not reveal an inverted viewport away from zero and recovers after retry', () => { + const view = mount([message('latest', 'bot')], 0, false, true, true); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + view.current.listProps.onScroll(event(END - 180)); + }); + flushFrames(); + expect(view.current.ready).toBe(false); + expect(view.current.canRetry).toBe(true); + act(() => { + view.current.retry(); + view.current.listProps.onScroll(event(END)); + }); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.current.canRetry).toBe(false); + } finally { + view.unmount(); + } + }); + + it('waits for keyboard handoff before revealing an initial inverted viewport', () => { + const view = mount([message('latest', 'bot')], 0, false, true, true); + try { + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + act(() => { + for (const callback of frames.splice(0)) callback(); + }); + act(() => view.current.keyboard.onStart()); + flushFrames(); + expect(view.current.ready).toBe(false); + act(() => { + view.current.keyboard.onInsetChange({ bottom: 300 }); + view.current.listProps.onLayout(layout(LAYOUT - 300)); + view.current.keyboard.onEnd(); + }); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); + } finally { + view.unmount(); + } + }); + + it('positions at zero without measuring all older rows', () => { + const view = mount([message('latest', 'bot')], 0, false, true, true); + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + expect(view.current.ready).toBe(true); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); + view.unmount(); + }); + it('holds reading while content grows and explicitly returns to zero', () => { + const view = mount([message('latest', 'bot')], 0, false, true, true); + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + view.scrollToOffset.mockClear(); + act(() => { + view.current.listProps.onScrollBeginDrag(); + view.current.listProps.onScrollEndDrag(event(END - 180)); + view.current.listProps.onContentSizeChange(0, CONTENT + 300); + }); + flushFrames(); + expect(view.current.away).toBe(true); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => view.current.jumpToLatest()); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: true }); + view.unmount(); + }); + it('keeps latest at zero after keyboard handoff', () => { + const view = mount([message('latest', 'bot')], 0, false, true, true); + act(() => { + view.current.listProps.onLayout(layout(LAYOUT)); + view.current.listProps.onContentSizeChange(0, CONTENT); + }); + flushFrames(); + view.scrollToOffset.mockClear(); + act(() => { + view.current.keyboard.onStart(); + view.current.keyboard.onInsetChange({ bottom: 300 }); + view.current.listProps.onContentSizeChange(0, CONTENT + 400); + }); + flushFrames(); + expect(view.scrollToOffset).not.toHaveBeenCalled(); + act(() => view.current.keyboard.onEnd()); + flushFrames(); + expect(view.scrollToOffset).toHaveBeenLastCalledWith({ offset: 0, animated: false }); + view.unmount(); + }); +}); From 19ea8f44e56348dab3c5f189320f1f7a788cec88 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Wed, 11 Nov 2026 22:00:54 +0900 Subject: [PATCH 4/6] docs(changes): note the Android chat scroll and swipe fixes Co-Authored-By: Claude Opus 5.5 --- changes/3955-android-chat-scroll-gestures.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 changes/3955-android-chat-scroll-gestures.md diff --git a/changes/3955-android-chat-scroll-gestures.md b/changes/3955-android-chat-scroll-gestures.md new file mode 100644 index 0000000000..81531318cb --- /dev/null +++ b/changes/3955-android-chat-scroll-gestures.md @@ -0,0 +1,11 @@ +--- +section: Fixed +--- + +## en + +- On Android, wide code blocks and tables inside a chat message scroll sideways again instead of starting a swipe-to-reply, and a message sent with the keyboard open now lands fully above the composer instead of half behind it ([#3955](https://github.com/BotHarness/DeepSeekBot/issues/3955)). + +## zh + +- Android 上,聊天消息里的宽代码块和表格又可以横向滚动,不会再误触发左滑回复;键盘打开时发出的消息会完整显示在输入框上方,不再有一半被挡住([#3955](https://github.com/BotHarness/DeepSeekBot/issues/3955))。 From 2f1340d9297545580e1ab2115fa3d1042b35ca90 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Wed, 11 Nov 2026 22:09:18 +0900 Subject: [PATCH 5/6] chore(mobile): drop a stray shell crash dump Co-Authored-By: Claude Opus 5.5 --- apps/mobile/grep.exe.stackdump | 30 ------------------------------ 1 file changed, 30 deletions(-) delete mode 100644 apps/mobile/grep.exe.stackdump diff --git a/apps/mobile/grep.exe.stackdump b/apps/mobile/grep.exe.stackdump deleted file mode 100644 index 5981550209..0000000000 --- a/apps/mobile/grep.exe.stackdump +++ /dev/null @@ -1,30 +0,0 @@ -Stack trace: -Frame Function Args -0007FFFFBD30 0002100615B7 (000000000002, 000000000002, 000000000000, 0007FFFFDC10) msys-2.0.dll+0x215B7 -000000000000 0002100636B5 (0007FFFFC6E0, 000000000000, 0000000000B0, 000000000000) msys-2.0.dll+0x236B5 -0007FFFFC440 0002100C3CBB (000000000000, 934CE0E80E43, 000000000000, 000000000000) msys-2.0.dll+0x83CBB -0000000000C1 0002100BF34B (000210229CCD, 7FFC00000000, 000000000000, 000000000000) msys-2.0.dll+0x7F34B -0007FFFFC808 0002100BF755 (0002101138DB, 0007FFFFCAC0, 0000000000B5, 0000000000B5) msys-2.0.dll+0x7F755 -0007FFFFC808 00021021A7D8 (0007FFFFC88D, 000000000003, 0007FFFFC898, 000000000000) msys-2.0.dll+0x1DA7D8 -0007FFFFC808 00010042A1E5 (0000000001F0, 000000000000, 000000000000, 000A00000628) grep.exe+0x2A1E5 -0007FFFFCB40 000100404B7C (000000000016, 0007FFFFCB40, 00021005BFDF, 000000000000) grep.exe+0x4B7C -0007FFFFCB40 000100429678 (000000000000, 0007FFFFCD30, 00080000AD88, 0007FFFFCDF0) grep.exe+0x29678 -0007FFFFCD30 000210048140 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x8140 -0007FFFFFFF0 000210045C86 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x5C86 -0007FFFFFFF0 000210045D34 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x5D34 -End of stack trace -Loaded modules: -000100400000 grep.exe -7FFC3C570000 ntdll.dll -7FFC3BB40000 KERNEL32.DLL -7FFC397D0000 KERNELBASE.dll -0005603F0000 msys-iconv-2.dll -000430B30000 msys-intl-8.dll -000210040000 msys-2.0.dll -0004C36D0000 msys-pcre-1.dll -7FFC3B770000 advapi32.dll -7FFC3BD10000 msvcrt.dll -7FFC3ABE0000 sechost.dll -7FFC3A3F0000 RPCRT4.dll -7FFC38B70000 CRYPTBASE.DLL -7FFC39E80000 bcryptPrimitives.dll From 48bc00544ef1f592b9276022648a54d56565f7f3 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Wed, 11 Nov 2026 22:10:21 +0900 Subject: [PATCH 6/6] test(mobile): check that a bubble's horizontal scroller blocks the real reply pan Co-Authored-By: Claude Opus 5.5 --- apps/mobile/test/reply-swipe.test.tsx | 52 +++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/mobile/test/reply-swipe.test.tsx b/apps/mobile/test/reply-swipe.test.tsx index 925601e51d..2b76fdb5a1 100644 --- a/apps/mobile/test/reply-swipe.test.tsx +++ b/apps/mobile/test/reply-swipe.test.tsx @@ -14,7 +14,12 @@ type Pan = { finalize(): void; }; -const state = vi.hoisted(() => ({ pans: [] as Pan[], haptics: [] as string[] })); +const state = vi.hoisted(() => ({ + pans: [] as Pan[], + haptics: [] as string[], + detected: [] as unknown[], + natives: [] as { blocks: unknown[] }[], +})); vi.mock('react-native', () => import('./react-native-shim')); vi.mock('react-native-reanimated', () => import('./reanimated-shim')); @@ -47,14 +52,28 @@ vi.mock('react-native-gesture-handler', async () => { }; return builder; }, + Native: () => { + const native = { blocks: [] as unknown[] }; + state.natives.push(native); + const builder = { + blocksExternalGesture: (...gestures: unknown[]) => ( + native.blocks.push(...gestures), + builder + ), + }; + return builder; + }, + }, + GestureDetector: ({ gesture, children }: { gesture: unknown; children: unknown }) => { + state.detected.push(gesture); + return createElement(Fragment, null, children as never); }, - GestureDetector: ({ children }: { children: unknown }) => - createElement(Fragment, null, children as never), }; }); const { ReplySwipe } = await import('../src/ui/chat/reply-swipe'); const { ReplyBar } = await import('../src/ui/chat/reply-bar'); +const { HorizontalScroll } = await import('../src/ui/primitives/horizontal-scroll'); const { horizontalSwipeOwner, replyTravel, repliesAt } = await import('../src/ui/chat/reply-swipe-model'); const { swipeReply } = await import('../src/ui/design/tokens'); @@ -69,6 +88,8 @@ describe('swipe left to reply', () => { beforeEach(() => { state.pans.length = 0; state.haptics.length = 0; + state.detected.length = 0; + state.natives.length = 0; container = document.createElement('div'); root = createRoot(container); }); @@ -77,6 +98,31 @@ describe('swipe left to reply', () => { await act(async () => root.unmount()); }); + it('lets a horizontal scroller in the bubble block the reply pan', async () => { + await act(async () => + root.render( + undefined}> + wide table + , + ), + ); + const pan = state.detected[0]; + expect(state.natives).toHaveLength(1); + expect(state.natives[0]!.blocks).toEqual([pan]); + expect(state.detected.at(-1)).not.toBe(pan); + }); + + it('leaves a scroller plain when the bubble cannot be replied to', async () => { + await act(async () => + root.render( + undefined}> + wide table + , + ), + ); + expect(state.natives).toHaveLength(0); + }); + it('follows the finger up to its reach, then resists, and commits past the threshold', () => { expect(replyTravel(-40)).toBe(0); expect(replyTravel(30)).toBe(30);