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}
+
+ )
+}