fix(mobile): let the chat transcript scroll by touch on the phone's web pages (#41578)

* fix(mobile): let the chat transcript scroll by touch on the phone's web pages

Gesture handler 3 (Expo 58 upgrade, #37935) gives a GestureDetector's view
touch-action: none on web by default. The chat transcript's pinch-to-zoom
detector sits on its scroller, so in the phone's WebView pages the browser
refused every touch pan and no chat could scroll. Gesture handler 2 left it
at auto.

The row list now wraps its own scroller in MobileNativeChatPinchZoom, which
keeps touch-action: auto. A browser render check pins the scroller's
touch-action under the detector.

* fix(mobile): keep the transcript pinch on its text size, not page zoom

touch-action: auto let a two-finger pinch on the transcript zoom the whole
page in the phone's web pages, so the transcript's text-size pinch never ran
and a zoomed page stopped the composer lifting over the keyboard.
pan-x pan-y keeps one-finger scrolling (and sideways code blocks) with the
browser and hands the pinch to the detector.

The render check now pans the transcript with one finger and pinches it
with two, asserting it scrolls, the text scale changes and the page stays
at scale 1.
This commit is contained in:
Jinwoo Hong
2026-11-12 14:10:05 -05:00
committed by GitHub
parent b10c07fbc3
commit fb5bf4450d
4 changed files with 206 additions and 41 deletions
@@ -0,0 +1,141 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import * as esbuild from 'esbuild'
import { chromium } from 'playwright-core'
import {
MOBILE_WEB_APP_ROOT_RESET,
mobileWebAppBuildOptions
} from '../../build/mobile-web-app/build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from '../mobile-web-app-bundle-dependencies.mjs'
import { createBundleServer, readShellCsp } from '../preview/mobile-web-app-render-harness.mjs'
// The phone's web pages must pan the transcript by touch and give a pinch to its text-size detector, not page zoom.
const mobileDir = fileURLToPath(new URL('../../../../mobile', import.meta.url))
const entry = `
import { createRoot } from 'react-dom/client'
import { Text, View } from 'react-native'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { MobileNativeChatPageScroll } from './src/session/native-chat/transcript/MobileNativeChatPageScroll'
import {
MobileNativeChatPinchZoom,
useMobileNativeChatPinchGesture
} from './src/session/native-chat/transcript/mobile-native-chat-pinch-zoom'
function Transcript() {
const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture('chat')
return <View style={{ height: 600, width: '100%' }}>
<Text testID="font-scale">{fontScale}</Text>
<GestureHandlerRootView style={{ flex: 1 }}>
<MobileNativeChatPinchZoom gesture={pinchGesture}>
<MobileNativeChatPageScroll testID="transcript">
{Array.from({ length: 60 }, (_, i) => <Text key={i}>message {i}</Text>)}
</MobileNativeChatPageScroll>
</MobileNativeChatPinchZoom>
</GestureHandlerRootView>
</View>
}
createRoot(document.getElementById('root')).render(<Transcript />)
`
const bundles = mobileWebAppDependenciesPresent()
const describeRender = bundles ? describe : describe.skip
let scratch
let server
let origin
let browser
beforeAll(async () => {
if (!bundles) {
return
}
await mkdir(join(mobileDir, '.tmp'), { recursive: true })
scratch = await mkdtemp(join(mobileDir, '.tmp', 'chat-touch-scroll-'))
const outDir = join(scratch, 'bundle')
await esbuild.build({
...mobileWebAppBuildOptions([]),
entryPoints: undefined,
stdin: { contents: entry, resolveDir: mobileDir, loader: 'tsx', sourcefile: 'transcript.tsx' },
splitting: false,
write: true,
outdir: outDir,
entryNames: 'transcript',
metafile: false
})
await writeFile(
join(outDir, 'index.html'),
'<!doctype html><html><head><meta charset="utf-8">' +
'<meta name="viewport" content="width=device-width, initial-scale=1">' +
`${MOBILE_WEB_APP_ROOT_RESET}</head>` +
'<body><div id="root"></div><script type="module" src="/transcript.js"></script></body></html>'
)
const served = await createBundleServer({ outDir, cspHeader: await readShellCsp() })
server = served.server
origin = served.origin
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
}, 180_000)
afterAll(async () => {
await browser?.close()
server?.close()
if (scratch) {
await rm(scratch, { recursive: true, force: true })
}
})
async function touch(page, steps) {
const cdp = await page.context().newCDPSession(page)
const [first, ...rest] = steps
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: first })
for (const touchPoints of rest) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints })
await page.evaluate(() => new Promise(requestAnimationFrame))
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await cdp.detach()
}
const frames = (count, at) => Array.from({ length: count + 1 }, (_, step) => at(step / count))
async function openTranscript() {
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
hasTouch: true,
isMobile: true
})
const page = await context.newPage()
await page.goto(`${origin}/`)
await page.getByTestId('transcript').waitFor()
return page
}
describeRender('native chat transcript in the phone web pages', () => {
it('pans the transcript under one finger', async () => {
const page = await openTranscript()
await touch(
page,
frames(10, (t) => [{ x: 195, y: 200 + 300 * t }])
)
await expect
.poll(() => page.getByTestId('transcript').evaluate((element) => element.scrollTop))
.toBeLessThan(-100)
await page.context().close()
})
it('gives a pinch to the text size, not to page zoom', async () => {
const page = await openTranscript()
await touch(
page,
frames(10, (t) => [
{ x: 195, y: 330 - 120 * t, id: 0 },
{ x: 195, y: 370 + 120 * t, id: 1 }
])
)
await expect.poll(() => page.getByTestId('font-scale').textContent()).not.toBe('1')
expect(await page.evaluate(() => window.visualViewport.scale)).toBe(1)
await page.context().close()
})
})
@@ -9,7 +9,7 @@ import {
import { type NativeScrollEvent, type NativeSyntheticEvent, View } from 'react-native'
import { MobileNativeChatSendErrorBanner } from './MobileNativeChatSendErrorBanner'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import type { NativeChatMessage } from '../../../../src/shared/native-chat/native-chat-types'
import type {
NativeChatLiveTurnIndicator,
@@ -31,7 +31,7 @@ import {
} from './transcript/mobile-native-chat-render-data'
import { useMobileNativeChatTransientData } from './transcript/use-mobile-native-chat-transient-data'
import { useMobileNativeChatRowKeys } from './transcript/mobile-native-chat-row-keys'
import { useMobileNativeChatPinchGesture } from './transcript/use-mobile-native-chat-pinch-gesture'
import { useMobileNativeChatPinchGesture } from './transcript/mobile-native-chat-pinch-zoom'
import { useMobileNativeChatTailFollow } from './transcript/use-mobile-native-chat-tail-follow'
import { useMobileNativeChatTurnDisclosure } from './transcript/use-mobile-native-chat-turn-disclosure'
import { mobileNativeChatComposerPlaceholder } from './composer/use-mobile-native-chat-input-lease'
@@ -404,16 +404,15 @@ export function MobileNativeChatView({
roots={disclosures.roots}
runPlacements={rowKeys.runPlacements}
>
<GestureDetector gesture={pinchGesture}>
<MobileNativeChatRowList
{...{ tail, rowKeys, renderItem, footer, emptyStateView, onScroll, layoutKey }}
{...{ header, hasHeader: hasMore === true, onNearTop: loadEarlierNearTop }}
rows={turns.listMessages}
runs={turns.presentation.workRuns.heads}
roots={disclosures.roots}
contentContainerStyle={styles.listContent}
/>
</GestureDetector>
<MobileNativeChatRowList
{...{ tail, rowKeys, renderItem, footer, emptyStateView, onScroll, layoutKey }}
{...{ header, hasHeader: hasMore === true, onNearTop: loadEarlierNearTop }}
rows={turns.listMessages}
runs={turns.presentation.workRuns.heads}
roots={disclosures.roots}
contentContainerStyle={styles.listContent}
pinchGesture={pinchGesture}
/>
</MobileNativeChatScrollRootsBoundary>
</MobileNativeChatDisclosureContext.Provider>
<MobileNativeChatFloatingControls
@@ -1,5 +1,7 @@
import type { ComponentProps, ReactElement } from 'react'
import { FlatList, Platform } from 'react-native'
import type { LegacyComposedGesture } from 'react-native-gesture-handler'
import { MobileNativeChatPinchZoom } from './mobile-native-chat-pinch-zoom'
import type { MobileNativeChatRowKeys } from './mobile-native-chat-row-keys'
import { MobileNativeChatPageList } from './MobileNativeChatPageList'
import {
@@ -27,6 +29,7 @@ type Props = Pick<
rowKeys: MobileNativeChatRowKeys
/** With a header, the row below it anchors the native list's visible content. */
hasHeader: boolean
pinchGesture: LegacyComposedGesture
}
export function MobileNativeChatRowList(props: Props): ReactElement {
@@ -35,35 +38,39 @@ export function MobileNativeChatRowList(props: Props): ReactElement {
if (Platform.OS === 'web') {
const { roots, runs, layoutKey, onNearTop } = props
return (
<MobileNativeChatPageList
{...tail.pageListProps}
{...{ rows, rowKeys, roots, renderItem, runs, contentContainerStyle, layoutKey }}
{...{ header, footer, emptyStateView, onScroll, onNearTop }}
/>
<MobileNativeChatPinchZoom gesture={props.pinchGesture}>
<MobileNativeChatPageList
{...tail.pageListProps}
{...{ rows, rowKeys, roots, renderItem, runs, contentContainerStyle, layoutKey }}
{...{ header, footer, emptyStateView, onScroll, onNearTop }}
/>
</MobileNativeChatPinchZoom>
)
}
return (
<FlatList
{...tail.listProps}
data={rows}
keyExtractor={rowKeys.keyOf}
renderItem={renderItem}
contentContainerStyle={contentContainerStyle}
// Let link/file taps land while the composer keyboard is up
// instead of being swallowed by the dismiss gesture.
keyboardShouldPersistTaps="handled"
onScroll={onScroll}
onScrollBeginDrag={tail.beginUserScroll}
onScrollEndDrag={tail.endUserDrag}
onMomentumScrollBegin={tail.beginMomentum}
onMomentumScrollEnd={tail.endMomentum}
scrollEventThrottle={32}
onContentSizeChange={tail.pinToTailAfterContentResize}
onLayout={tail.pinToTail}
maintainVisibleContentPosition={mobileNativeChatRowAnchor(props.hasHeader)}
ListHeaderComponent={header ?? undefined}
ListFooterComponent={footer ?? undefined}
ListEmptyComponent={emptyStateView ?? undefined}
/>
<MobileNativeChatPinchZoom gesture={props.pinchGesture}>
<FlatList
{...tail.listProps}
data={rows}
keyExtractor={rowKeys.keyOf}
renderItem={renderItem}
contentContainerStyle={contentContainerStyle}
// Let link/file taps land while the composer keyboard is up
// instead of being swallowed by the dismiss gesture.
keyboardShouldPersistTaps="handled"
onScroll={onScroll}
onScrollBeginDrag={tail.beginUserScroll}
onScrollEndDrag={tail.endUserDrag}
onMomentumScrollBegin={tail.beginMomentum}
onMomentumScrollEnd={tail.endMomentum}
scrollEventThrottle={32}
onContentSizeChange={tail.pinToTailAfterContentResize}
onLayout={tail.pinToTail}
maintainVisibleContentPosition={mobileNativeChatRowAnchor(props.hasHeader)}
ListHeaderComponent={header ?? undefined}
ListFooterComponent={footer ?? undefined}
ListEmptyComponent={emptyStateView ?? undefined}
/>
</MobileNativeChatPinchZoom>
)
}
@@ -1,5 +1,5 @@
import { useMemo, useRef, useState } from 'react'
import { Gesture } from 'react-native-gesture-handler'
import { useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import type { LegacyComposedGesture } from 'react-native-gesture-handler'
import { quantizeFontScale } from './mobile-native-chat-message-text'
@@ -39,3 +39,21 @@ export function useMobileNativeChatPinchGesture(scopeKey: string): {
return { fontScale, pinchGesture }
}
// Web: gesture handler 3 defaults to touch-action: none (froze touch scrolling); auto would let a pinch zoom the page instead of the text.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: valid CSS that the handler copies verbatim to style.touchAction; its union lists single keywords only.
const PAN_WITHOUT_PAGE_ZOOM = 'pan-x pan-y' as ComponentProps<typeof GestureDetector>['touchAction']
export function MobileNativeChatPinchZoom({
gesture,
children
}: {
gesture: LegacyComposedGesture
children: ReactNode
}) {
return (
<GestureDetector gesture={gesture} touchAction={PAN_WITHOUT_PAGE_ZOOM}>
{children}
</GestureDetector>
)
}