From fb5bf4450db38cc662da07eee4cbaf83a2f59ccb Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Thu, 12 Nov 2026 14:10:05 -0500
Subject: [PATCH] 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.
---
...p-native-chat-touch-scroll-render.test.mjs | 141 ++++++++++++++++++
.../native-chat/MobileNativeChatView.tsx | 23 ++-
.../transcript/MobileNativeChatRowList.tsx | 61 ++++----
...e.ts => mobile-native-chat-pinch-zoom.tsx} | 22 ++-
4 files changed, 206 insertions(+), 41 deletions(-)
create mode 100644 config/scripts/mobile-web-app/session/mobile-web-app-native-chat-touch-scroll-render.test.mjs
rename mobile/src/session/native-chat/transcript/{use-mobile-native-chat-pinch-gesture.ts => mobile-native-chat-pinch-zoom.tsx} (64%)
diff --git a/config/scripts/mobile-web-app/session/mobile-web-app-native-chat-touch-scroll-render.test.mjs b/config/scripts/mobile-web-app/session/mobile-web-app-native-chat-touch-scroll-render.test.mjs
new file mode 100644
index 00000000000..72f2380b7ed
--- /dev/null
+++ b/config/scripts/mobile-web-app/session/mobile-web-app-native-chat-touch-scroll-render.test.mjs
@@ -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
+ {fontScale}
+
+
+
+ {Array.from({ length: 60 }, (_, i) => message {i})}
+
+
+
+
+}
+createRoot(document.getElementById('root')).render()
+`
+
+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'),
+ '
' +
+ '' +
+ `${MOBILE_WEB_APP_ROOT_RESET}` +
+ ''
+ )
+ 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()
+ })
+})
diff --git a/mobile/src/session/native-chat/MobileNativeChatView.tsx b/mobile/src/session/native-chat/MobileNativeChatView.tsx
index 5e701c297d3..5fae6bdcdae 100644
--- a/mobile/src/session/native-chat/MobileNativeChatView.tsx
+++ b/mobile/src/session/native-chat/MobileNativeChatView.tsx
@@ -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}
>
-
-
-
+
+
+
+
)
}
return (
-
+
+
+
)
}
diff --git a/mobile/src/session/native-chat/transcript/use-mobile-native-chat-pinch-gesture.ts b/mobile/src/session/native-chat/transcript/mobile-native-chat-pinch-zoom.tsx
similarity index 64%
rename from mobile/src/session/native-chat/transcript/use-mobile-native-chat-pinch-gesture.ts
rename to mobile/src/session/native-chat/transcript/mobile-native-chat-pinch-zoom.tsx
index 13dc49e9ee1..b6e60b0cc4e 100644
--- a/mobile/src/session/native-chat/transcript/use-mobile-native-chat-pinch-gesture.ts
+++ b/mobile/src/session/native-chat/transcript/mobile-native-chat-pinch-zoom.tsx
@@ -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['touchAction']
+
+export function MobileNativeChatPinchZoom({
+ gesture,
+ children
+}: {
+ gesture: LegacyComposedGesture
+ children: ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}