diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs index 1bfad89b6e7..09f5f913a94 100644 --- a/config/scripts/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -139,9 +139,17 @@ export const MOBILE_WEB_APP_ROOT_RESET = * Inputs and textareas: Chromium rings a focused one (`:focus-visible` matches every focused * text field); no native TextInput paints one, and the caret and the IME already mark focus. * Those only: a button reached by a hardware keyboard keeps the browser's ring. + * + * Text: native `Text` is unselectable unless `selectable`; react-native-web's is selectable. On + * iOS a hold on it raises WebKit's selection over the page's own long press; on Android the + * selection's `selectionchange` and `touchcancel` end a held press. `body`, not `#root`: modals + * portal to `document.body`. Fields opt back in, callout too, since WebKit inherits it. */ export const MOBILE_WEB_APP_NATIVE_PARITY_STYLE = - '' + '' const PAGE_ASYNC_STORAGE_MODULE = join( mobileDir, diff --git a/config/scripts/mobile-web-app-held-press-probe-routes.mjs b/config/scripts/mobile-web-app-held-press-probe-routes.mjs new file mode 100644 index 00000000000..7537ec840cf --- /dev/null +++ b/config/scripts/mobile-web-app-held-press-probe-routes.mjs @@ -0,0 +1,271 @@ +/** + * Scratch routes for the held-press check: the surfaces whose press must outlive Android WebView's + * long-press. Each mounts the shipped component with stubbed effects; what is under test is the + * press responder, so handlers only count. + */ + +/** RN Web renders `nativeID` as the DOM `id`; the check touches each mic through it. */ +export const TERMINAL_MIC_ID = 'hold-probe-terminal-mic' +export const CHAT_MIC_ID = 'hold-probe-chat-mic' + +export function holdDictationProbeRouteSource({ terminalActionsModule, chatComposerModule }) { + return `import { useRef, useState } from 'react' +import { Text, View } from 'react-native' +import { MobileTerminalInputActions } from ${JSON.stringify(terminalActionsModule)} +import { MobileNativeChatComposer } from ${JSON.stringify(chatComposerModule)} + +const noop = () => {} + +export default function HoldDictationProbeRoute() { + const [terminalHeld, setTerminalHeld] = useState(false) + const [chatHeld, setChatHeld] = useState(false) + const pressOutsRef = useRef({ terminal: 0, chat: 0 }) + globalThis.__orcaHeldPressProbe = { pressOuts: () => ({ ...pressOutsRef.current }) } + return ( + + Model + + setTerminalHeld(true)} + onDictationPressOut={() => { + pressOutsRef.current.terminal += 1 + setTerminalHeld(false) + }} + onDictationCancel={noop} + /> + + + Promise.resolve(false)} + sendSurfaceId="hold-probe" + getSendCompletionGeneration={() => 0} + getComposerEditGeneration={() => 0} + onMicPress={noop} + micActive={chatHeld} + dictationMode="hold" + onMicPressIn={() => setChatHeld(true)} + onMicPressOut={() => { + pressOutsRef.current.chat += 1 + setChatHeld(false) + }} + /> + + + ) +} +` +} + +export const REPEAT_KEY_LABEL = 'Arrow Up' +export const TAP_KEY_LABEL = 'Escape' + +/** + * The session's command dock with a stub controller, in buffered mode. The repeat cadence is + * `use-mobile-session-accessory-selection.ts`'s (send on press-in, repeat after 400 ms, then every + * 45 ms, stop on press-out), restated here because that hook is bound to the whole session. + */ +export function commandDockProbeRouteSource({ commandDockModule, keyDefinitionsModule }) { + return `import { useRef } from 'react' +import { MobileSessionCommandDock } from ${JSON.stringify(commandDockModule)} +import { TERMINAL_ACCESSORY_KEY_DEFINITIONS } from ${JSON.stringify(keyDefinitionsModule)} + +const noop = () => {} +const KEYS = TERMINAL_ACCESSORY_KEY_DEFINITIONS.filter((key) => ['arrowUp', 'escape'].includes(key.id)) +const IDLE = { isStarting: false, isRecording: false, isProcessing: false } + +export default function CommandDockProbeRoute() { + const sentRef = useRef([]) + const timersRef = useRef({ timeout: null, interval: null }) + const stop = () => { + clearTimeout(timersRef.current.timeout) + clearInterval(timersRef.current.interval) + } + const send = (input) => { + sentRef.current.push(input.bytes) + return Promise.resolve() + } + const start = (input) => { + stop() + timersRef.current.timeout = setTimeout(() => { + timersRef.current.interval = setInterval(() => void send(input), 45) + }, 400) + } + globalThis.__orcaHeldPressProbe = { sent: () => [...sentRef.current] } + const controller = { + insets: { top: 0, bottom: 0, left: 0, right: 0 }, + bufferedTerminalDraftState: { input: '', setInput: noop }, + autocompleteEnabled: false, + liveInputCapture: '', + activeHandle: 'held-press-terminal', + customKeys: [], + setShowCustomKeyModal: noop, + setDeleteKeyTarget: noop, + visibleBuiltInAccessoryKeys: KEYS, + terminalModes: new Map(), + canPaste: false, + dictationMode: 'toggle', + bindCommandField: noop, + handleLiveInputChange: noop, + handleLiveInputKeyPress: noop, + bindLiveInputField: noop, + submitLiveInput: noop, + canSend: true, + canCompose: true, + liveInputEnabled: false, + focusLiveInput: noop, + showNativeChat: false, + dictation: IDLE, + cancelDictation: noop, + handleDictationToggle: noop, + handleDictationPressIn: noop, + handleDictationPressOut: noop, + toggleDisplayMode: noop, + handleSend: () => Promise.resolve(), + handleAccessoryKey: send, + dismissSoftwareKeyboard: noop, + toggleLiveInput: noop, + stopAccessoryRepeat: stop, + startAccessoryRepeat: start, + handlePaste: () => Promise.resolve(), + isAttaching: false, + attachImage: () => Promise.resolve(), + activeMarkdownTab: null, + activeFileTab: null, + activeBrowserTab: null, + keyboardLift: 0 + } + return +} +` +} + +export const BROWSER_VIEWPORT_ID = 'held-press-browser-viewport' + +/** + * The browser pane's view with its real interactions hook, which owns the 550 ms long-press + * right-click. The stub client records each request, so a right-click is read where the host would + * see it, not only from the hook's "Right click" toast. + */ +export function browserPaneProbeRouteSource({ + paneViewModule, + interactionsModule, + geometryModule +}) { + return `import { useRef, useState } from 'react' +import { View } from 'react-native' +import { MobileBrowserPaneView } from ${JSON.stringify(paneViewModule)} +import { useMobileBrowserInteractions } from ${JSON.stringify(interactionsModule)} +import { computeBrowserFrameGeometry } from ${JSON.stringify(geometryModule)} + +const noop = () => {} +const LAYOUT = { width: 390, height: 500 } +const LAYER = { attachView: noop, attachImage: noop, onLoad: noop, onError: noop } +const TAB = { + type: 'browser', + id: 'held-press-tab', + title: 'Example', + browserWorkspaceId: 'held-press-workspace', + browserPageId: 'held-press-page', + url: 'https://example.com/', + loading: false, + canGoBack: false, + canGoForward: false +} + +export default function BrowserPaneProbeRoute() { + const toastsRef = useRef([]) + const requestsRef = useRef([]) + const clientRef = useRef({ + sendRequest: (method, params) => { + requestsRef.current.push({ method, params }) + return Promise.resolve({ ok: true, id: String(requestsRef.current.length), result: {} }) + } + }) + const layoutRef = useRef(LAYOUT) + const longPressTimerRef = useRef(null) + const zoom = { scale: 1, offsetX: 0, offsetY: 0 } + const [dialog, setDialog] = useState(null) + const dialogRef = useRef(null) + dialogRef.current = dialog + const frameGeometry = computeBrowserFrameGeometry(LAYOUT, null) + const interactions = useMobileBrowserInteractions({ + clearLongPressTimer: () => clearTimeout(longPressTimerRef.current), + client: clientRef.current, + dialogRef, + frameGeometry, + frameMetadataRef: { current: null }, + keyboardValue: '', + layoutRef, + longPressTimerRef, + onToast: (message) => toastsRef.current.push(message), + pageParams: () => ({ worktree: 'held-press-worktree', page: TAB.browserPageId }), + panRef: useRef(null), + pinchRef: useRef(null), + pointerModifiers: [], + sendBrowserRequest: () => Promise.resolve(null), + scrollingRef: useRef(false), + startPointRef: useRef(null), + setDialog, + setError: noop, + setKeyboardValue: noop, + setPointerModifiers: noop, + setZoom: noop, + zoomRef: { current: zoom } + }) + globalThis.__orcaHeldPressProbe = { + toasts: () => [...toastsRef.current], + requests: () => [...requestsRef.current] + } + return ( + + Promise.resolve()} + panResponder={interactions.panResponder} + pointerModifiers={[]} + reloadPage={noop} + renderedFrameSource={null} + selectBrowserViewMode={noop} + sendDialogCommand={interactions.sendDialogCommand} + sendKeyboardText={() => Promise.resolve()} + sendKeypress={() => Promise.resolve()} + setAddressFocused={noop} + setAddressValue={noop} + setKeyboardValue={noop} + setLayout={noop} + setRootViewRef={noop} + tab={TAB} + togglePointerModifier={noop} + zoom={zoom} + /> + + ) +} +` +} diff --git a/config/scripts/mobile-web-app-held-press-render.test.mjs b/config/scripts/mobile-web-app-held-press-render.test.mjs new file mode 100644 index 00000000000..279378f7063 --- /dev/null +++ b/config/scripts/mobile-web-app-held-press-render.test.mjs @@ -0,0 +1,326 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { chromium } from 'playwright-core' +import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' +import { + BROWSER_VIEWPORT_ID, + CHAT_MIC_ID, + REPEAT_KEY_LABEL, + TAP_KEY_LABEL, + TERMINAL_MIC_ID, + browserPaneProbeRouteSource, + commandDockProbeRouteSource, + holdDictationProbeRouteSource +} from './mobile-web-app-held-press-probe-routes.mjs' +import { MOBILE_WEB_APP_ROUTE_ROOT } from './mobile-web-app-route-manifest.mjs' +import { + createBundleServer, + installShellDouble, + projectDir, + readBridgeFaultGrant, + readBridgeProtocolVersion, + readShellCsp +} from './mobile-web-app-render-harness.mjs' +import { LAYOUT_SOURCE } from './mobile-web-app-terminal-probe-route.mjs' + +/** + * Held presses on the page. Held ~500 ms, Android WebView turns a touch into a long-press: it fires + * `contextmenu`, and where text can be selected it starts a selection and then cancels the touch. + * react-native-web ends a press on all three unless the press refuses `contextmenu` (a Pressable + * with `onLongPress`), and the page's native-parity style leaves no text to select. Traced on an + * emulator; headless Chromium generates no long-press from CDP touches, so `holdLikeAndroidWebView` + * plays it. + */ + +const ROUTES = { + mic: `/${MOBILE_WEB_APP_ROUTE_ROOT}/hold-dictation-probe`, + keys: `/${MOBILE_WEB_APP_ROUTE_ROOT}/command-dock-probe`, + browser: `/${MOBILE_WEB_APP_ROUTE_ROOT}/browser-pane-probe` +} +/** When Android WebView's long-press lands; a selection's cancel follows ~20-50 ms later. */ +const LONG_PRESS_MS = 500 +const bundles = mobileWebAppDependenciesPresent() +const describeRender = bundles ? describe : describe.skip + +let browser = null +let origin = null +let scratch = null +let server = null +let bridgeVersion = null +let faultGrant = null + +beforeAll(async () => { + if (!bundles) { + return + } + const sessionDir = join(projectDir, 'mobile', 'src', 'session') + const browserDir = join(projectDir, 'mobile', 'src', 'browser') + const terminalDir = join(projectDir, 'mobile', 'src', 'terminal') + const cspHeader = await readShellCsp() + bridgeVersion = await readBridgeProtocolVersion() + faultGrant = await readBridgeFaultGrant() + scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-held-press-')) + const appDir = join(scratch, 'app') + const routeDir = join(appDir, MOBILE_WEB_APP_ROUTE_ROOT) + await mkdir(routeDir, { recursive: true }) + await writeFile(join(routeDir, '_layout.tsx'), LAYOUT_SOURCE) + await writeFile( + join(routeDir, 'hold-dictation-probe.tsx'), + holdDictationProbeRouteSource({ + terminalActionsModule: join(sessionDir, 'MobileTerminalInputActions'), + chatComposerModule: join(sessionDir, 'MobileNativeChatComposer') + }) + ) + await writeFile( + join(routeDir, 'command-dock-probe.tsx'), + commandDockProbeRouteSource({ + commandDockModule: join(sessionDir, 'MobileSessionCommandDock'), + keyDefinitionsModule: join(terminalDir, 'terminal-key-definitions') + }) + ) + await writeFile( + join(routeDir, 'browser-pane-probe.tsx'), + browserPaneProbeRouteSource({ + paneViewModule: join(browserDir, 'MobileBrowserPaneView'), + interactionsModule: join(browserDir, 'use-mobile-browser-interactions'), + geometryModule: join(browserDir, 'browser-touch-geometry') + }) + ) + const built = await buildMobileWebAppBundle({ + appDir, + outDir: join(scratch, 'bundle'), + pageRoutes: Object.values(ROUTES).map((pathname) => ({ pathname, grants: [] })) + }) + const served = await createBundleServer({ outDir: built.outDir, cspHeader }) + server = served.server + origin = served.origin + const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER + browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) }) +}, 600_000) + +afterAll(async () => { + await browser?.close() + server?.close() + if (scratch) { + await rm(scratch, { recursive: true, force: true }) + } +}) + +async function openProbe(pathname) { + const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true }) + await page.addInitScript(installShellDouble, { + version: bridgeVersion, + sessionId: 'held-press-session', + buildId: 'held-press-build', + route: { pathname, params: {} }, + host: { id: 'held-host', name: 'Held Host', endpoint: 'ws://held', lastConnected: 1 }, + storage: {}, + faultGrant, + grants: [faultGrant], + pageRoutes: Object.values(ROUTES), + replies: {} + }) + const errors = [] + page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`)) + await page.goto(`${origin}/`, { waitUntil: 'load' }) + await page.waitForFunction( + () => + globalThis.__orcaHeldPressProbe !== undefined || + (globalThis.__orcaRenderCheckFaults ?? []).length > 0, + { timeout: 60_000, polling: 100 } + ) + expect(await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])).toEqual([]) + return { errors, page } +} + +/** + * Touch-holds `selector` for `holdMs`, reads `read()` mid-hold, then lifts. At 500 ms it plays + * Android WebView's long-press: `contextmenu`, then — only where the touch could select text — + * `selectionchange` and `touchcancel`, which is what the device did while page text was selectable. + */ +async function holdLikeAndroidWebView(page, selector, { holdMs, read }) { + // A handle, not a locator: a label the selector matches can change as soon as the press lands. + const target = await page.waitForSelector(selector) + const box = await target.boundingBox() + const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 } + const input = await page.context().newCDPSession(page) + await input.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] }) + await page.waitForTimeout(LONG_PRESS_MS) + const selectable = await target.evaluate((node, at) => { + node.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: at.x, + clientY: at.y + }) + ) + // The nearest explicit user-select up the tree decides, as Blink resolves `auto`. + for (let element = node; element; element = element.parentElement) { + const value = getComputedStyle(element).userSelect + if (value && value !== 'auto') { + return value !== 'none' + } + } + return true + }, point) + if (selectable) { + await page.evaluate(() => document.dispatchEvent(new Event('selectionchange'))) + await input.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] }) + } + await page.waitForTimeout(holdMs - LONG_PRESS_MS) + const midHold = { selectable, ...(await read(target)) } + if (!selectable) { + await input.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) + } + await page.waitForTimeout(300) + return { midHold, afterRelease: await read(target) } +} + +const pressOuts = (page) => page.evaluate(() => globalThis.__orcaHeldPressProbe.pressOuts()) + +describeRender( + 'held presses on the page', + () => { + describe('hold-to-dictate', () => { + it('keeps the terminal mic held until the finger lifts', async () => { + const { errors, page } = await openProbe(ROUTES.mic) + const { midHold, afterRelease } = await holdLikeAndroidWebView( + page, + `#${TERMINAL_MIC_ID} [aria-label="Start voice dictation"]`, + { + holdMs: 1500, + read: async (mic) => ({ + label: await mic.getAttribute('aria-label'), + pressOuts: await pressOuts(page) + }) + } + ) + expect(midHold).toEqual({ + selectable: false, + label: 'Stop voice dictation', + pressOuts: { terminal: 0, chat: 0 } + }) + expect(afterRelease.pressOuts).toEqual({ terminal: 1, chat: 0 }) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + + it('keeps the chat mic held until the finger lifts', async () => { + const { errors, page } = await openProbe(ROUTES.mic) + const { midHold, afterRelease } = await holdLikeAndroidWebView( + page, + `#${CHAT_MIC_ID} [aria-label="Dictate"]`, + { + holdMs: 1500, + read: async (mic) => ({ + label: await mic.getAttribute('aria-label'), + pressOuts: await pressOuts(page) + }) + } + ) + expect(midHold).toEqual({ + selectable: false, + label: 'Stop dictation', + pressOuts: { terminal: 0, chat: 0 } + }) + // The icon under the finger swaps on press, so the release has to reach the Pressable. + expect(afterRelease.pressOuts).toEqual({ terminal: 0, chat: 1 }) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + }) + + describe('the key bar', () => { + const sentCount = async (page) => + (await page.evaluate(() => globalThis.__orcaHeldPressProbe.sent())).length + + it('keeps repeating a held arrow key until the finger lifts', async () => { + const { errors, page } = await openProbe(ROUTES.keys) + const { midHold, afterRelease } = await holdLikeAndroidWebView( + page, + `[aria-label="${REPEAT_KEY_LABEL}"]`, + { holdMs: 1500, read: async () => ({ sent: await sentCount(page) }) } + ) + // One send on press-in, then every 45 ms from 400 ms: ~25 by 1.5 s, against 1 when the + // long-press ends the press before the first repeat. + expect(midHold.sent).toBeGreaterThan(20) + expect(midHold.selectable).toBe(false) + // Nothing more once the finger is up. + expect(afterRelease.sent - midHold.sent).toBeLessThan(3) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + + it('selects nothing on a long-press of a tap key', async () => { + const { errors, page } = await openProbe(ROUTES.keys) + const { midHold } = await holdLikeAndroidWebView(page, `[aria-label="${TAP_KEY_LABEL}"]`, { + holdMs: 1000, + read: () => page.evaluate(() => ({ selection: window.getSelection()?.toString() ?? '' })) + }) + expect(midHold).toEqual({ selectable: false, selection: '' }) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + + it('sends a tapped key once', async () => { + const { errors, page } = await openProbe(ROUTES.keys) + const key = await page.waitForSelector(`[aria-label="${TAP_KEY_LABEL}"]`) + await key.tap() + await page.waitForFunction(() => globalThis.__orcaHeldPressProbe.sent().length === 1) + expect(await page.evaluate(() => globalThis.__orcaHeldPressProbe.sent())).toEqual(['\x1b']) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + }) + + describe('the browser pane', () => { + const toasts = (page) => page.evaluate(() => globalThis.__orcaHeldPressProbe.toasts()) + const clicks = (page) => + page.evaluate(() => + globalThis.__orcaHeldPressProbe + .requests() + .filter((request) => request.method === 'browser.mouseClick') + .map((request) => ({ button: request.params.button, page: request.params.page })) + ) + + it('right-clicks on a 1 s hold', async () => { + const { errors, page } = await openProbe(ROUTES.browser) + const { midHold, afterRelease } = await holdLikeAndroidWebView( + page, + `#${BROWSER_VIEWPORT_ID}`, + { + holdMs: 1000, + read: async () => ({ toasts: await toasts(page), clicks: await clicks(page) }) + } + ) + // The hook's timer is 550 ms, after the WebView's long-press would have ended the press. + const rightClick = { button: 'right', page: 'held-press-page' } + expect(midHold).toEqual({ + selectable: false, + toasts: ['Right click'], + clicks: [rightClick] + }) + // The release after a right-click sends no left click. + expect(afterRelease.clicks).toEqual([rightClick]) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + + it('left-clicks once on a tap', async () => { + const { errors, page } = await openProbe(ROUTES.browser) + const viewport = await page.waitForSelector(`#${BROWSER_VIEWPORT_ID}`) + await viewport.tap() + await page.waitForFunction(() => globalThis.__orcaHeldPressProbe.requests().length > 0) + expect(await clicks(page)).toEqual([{ button: 'left', page: 'held-press-page' }]) + expect(await toasts(page)).toEqual([]) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + }) + }, + 900_000 +) diff --git a/config/scripts/mobile-web-app-native-parity-render.test.mjs b/config/scripts/mobile-web-app-native-parity-render.test.mjs index ff91f9b598b..d2074676613 100644 --- a/config/scripts/mobile-web-app-native-parity-render.test.mjs +++ b/config/scripts/mobile-web-app-native-parity-render.test.mjs @@ -47,7 +47,7 @@ const ENGINES = [ const PAGE_ENTRY = ` import { createElement as h } from 'react' import { createRoot } from 'react-dom/client' -const { StyleSheet, TextInput, View } = require('react-native') +const { Modal, StyleSheet, Text, TextInput, View } = require('react-native') const styles = StyleSheet.create({ separatorBox: { paddingTop: 10.1, width: 12, backgroundColor: '#ffffff' }, separator: { height: StyleSheet.hairlineWidth, backgroundColor: '#000000' }, @@ -55,7 +55,7 @@ const styles = StyleSheet.create({ input: { height: 40 } }) createRoot(document.getElementById('root')).render( - h(View, null, h(View, { testID: 'separator-box', style: styles.separatorBox }, h(View, { style: styles.separator })), h(View, { testID: 'hairline', style: styles.hairline }), h(TextInput, { testID: 'input', style: styles.input })) + h(View, null, h(View, { testID: 'separator-box', style: styles.separatorBox }, h(View, { style: styles.separator })), h(View, { testID: 'hairline', style: styles.hairline }), h(TextInput, { testID: 'input', style: styles.input }), h(Text, { testID: 'plain-text' }, 'row'), h(Text, { testID: 'selectable-text', selectable: true }, 'message'), location.hash === '#modal' ? h(Modal, { visible: true, transparent: true }, h(View, null, h(Text, { testID: 'modal-text' }, 'sheet row'), h(TextInput, { testID: 'modal-input', style: styles.input }))) : null) ) ` @@ -111,9 +111,9 @@ afterAll(async () => { } }) -async function openPage(engine) { +async function openPage(engine, hash = '') { const page = await browsers.get(engine.name).newPage(engine.pageOptions) - await page.goto(`${origin}/`, { waitUntil: 'domcontentloaded' }) + await page.goto(`${origin}/${hash}`, { waitUntil: 'domcontentloaded' }) await page.waitForSelector('[data-testid="input"]') return page } @@ -168,4 +168,81 @@ describeParity.each(ENGINES)('the page against native, at a phone density, in $n await page.close() } }) + + it('leaves only selectable text and editable fields selectable, as native does', async () => { + // The iOS shell keeps WebKit text interaction on so fields take text, and a hold on plain text + // would otherwise raise WebKit's selection over the page's own long press. + const page = await openPage(engine) + try { + await page.type('[data-testid="input"]', 'typed') + const measured = await page.evaluate(() => { + const select = (id) => getComputedStyle(document.querySelector(`[data-testid="${id}"]`)) + const read = (id) => select(id).webkitUserSelect || select(id).userSelect + return { + plain: read('plain-text'), + selectable: read('selectable-text'), + input: read('input'), + value: document.querySelector('[data-testid="input"]').value + } + }) + expect(measured).toEqual({ plain: 'none', selectable: 'text', input: 'text', value: 'typed' }) + } finally { + await page.close() + } + }) + + it("selects through the browser's own gesture only what native lets the user select", async () => { + // A double-click, not a Range: a script can select text a user cannot. + const page = await openPage(engine) + try { + const selectedBy = async (testId) => { + await page.evaluate(() => window.getSelection()?.removeAllRanges()) + await page.dblclick(`[data-testid="${testId}"]`) + return page.evaluate(() => window.getSelection()?.toString() ?? '') + } + expect(await selectedBy('plain-text')).toBe('') + // react-native-web's Text/index.js:115 adds `styles.selectable` (`userSelect: 'text'`). + expect(await selectedBy('selectable-text')).not.toBe('') + + await page.type('[data-testid="input"]', 'hello world') + await page.dblclick('[data-testid="input"]') + const selectedInField = await page.evaluate(() => { + const input = document.querySelector('[data-testid="input"]') + return input.selectionEnd - input.selectionStart + }) + expect(selectedInField).toBeGreaterThan(0) + } finally { + await page.close() + } + }) + + it('applies the same to text a modal portals outside #root, and keeps its fields selectable', async () => { + // react-native-web's Modal appends to document.body, so a rule on #root would miss sheets. + // The fields' callout exemption goes unmeasured: desktop WebKit lacks the property. + const page = await openPage(engine, '#modal') + try { + await page.waitForSelector('[data-testid="modal-text"]') + const measured = await page.evaluate(() => { + const text = document.querySelector('[data-testid="modal-text"]') + const input = document.querySelector('[data-testid="modal-input"]') + const read = (node) => + getComputedStyle(node).webkitUserSelect || getComputedStyle(node).userSelect + return { + outsideRoot: !document.getElementById('root').contains(text), + text: read(text), + input: read(input) + } + }) + expect(measured).toEqual({ + outsideRoot: true, + text: 'none', + input: 'text' + }) + await page.evaluate(() => window.getSelection()?.removeAllRanges()) + await page.dblclick('[data-testid="modal-text"]') + expect(await page.evaluate(() => window.getSelection()?.toString() ?? '')).toBe('') + } finally { + await page.close() + } + }) }) diff --git a/config/scripts/mobile-web-app-rich-markdown-render.test.mjs b/config/scripts/mobile-web-app-rich-markdown-render.test.mjs index d1133bce143..45b37eeba7c 100644 --- a/config/scripts/mobile-web-app-rich-markdown-render.test.mjs +++ b/config/scripts/mobile-web-app-rich-markdown-render.test.mjs @@ -20,7 +20,11 @@ import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import * as esbuild from 'esbuild' import { chromium, webkit } from 'playwright-core' -import { MOBILE_WEB_APP_ROOT_RESET, lucideBarrelPlugin } from './build-mobile-web-app-bundle.mjs' +import { + MOBILE_WEB_APP_NATIVE_PARITY_STYLE, + MOBILE_WEB_APP_ROOT_RESET, + lucideBarrelPlugin +} from './build-mobile-web-app-bundle.mjs' import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' import { textInputFontSizeFloor } from './mobile-web-app-text-input-font-size-seam.mjs' import { @@ -270,7 +274,8 @@ beforeAll(async () => { join(outDir, 'index.html'), // The root reset the shipped document carries: every box below the mount is `flex: 1`, so // without a definite height on all three the editor measures 0 and paints nothing. - `${MOBILE_WEB_APP_ROOT_RESET}` + + `${MOBILE_WEB_APP_ROOT_RESET}` + + `${MOBILE_WEB_APP_NATIVE_PARITY_STYLE}` + '
' + '' ) @@ -512,6 +517,28 @@ describeEditor( * a paragraph inline reported its own text with no marker, so the bullet the user pressed * was gone the moment the host saved what the document reported. */ + it('edits and selects under the shipped document style, which makes other text unselectable', async () => { + const { page, consoleErrors } = await openPage(browser) + try { + await setContent(page, 'alpha', '

alpha

') + const word = page.locator('#first-surface #editor p') + await word.click() + await page.keyboard.press('End') + await page.keyboard.type(' bravo') + await page.waitForFunction( + () => + document.querySelector('#first-surface #editor p')?.textContent === 'alpha bravo' + ) + // The browser's own gesture, not a Range: `body` is `user-select: none` on the page. + await page.evaluate(() => window.getSelection()?.removeAllRanges()) + await word.dblclick() + expect(await page.evaluate(() => window.getSelection()?.toString().trim())).not.toBe('') + expect(consoleErrors).toEqual([]) + } finally { + await page.close() + } + }, 600_000) + it('reports a typed bullet list as a list, and renders that markdown back as one', async () => { const { page, consoleErrors } = await openPage(browser) try { diff --git a/mobile/src/browser/use-mobile-browser-interactions.ts b/mobile/src/browser/use-mobile-browser-interactions.ts index 0f09dc595c7..00815377f0d 100644 --- a/mobile/src/browser/use-mobile-browser-interactions.ts +++ b/mobile/src/browser/use-mobile-browser-interactions.ts @@ -281,7 +281,9 @@ export function useMobileBrowserInteractions(args: MobileBrowserInteractionArgs) scrollingRef.current = false startPointRef.current = null }, - onPanResponderTerminationRequest: () => true + // The long-press right-click outlives the WebView's own long-press contextmenu at ~500 ms. + onPanResponderTerminationRequest: (event) => + !('type' in event.nativeEvent) || event.nativeEvent.type !== 'contextmenu' }), [clearLongPressTimer, handleResponderGrant, handleResponderMove, handleResponderRelease] ) diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index c5d3d7759cf..b04e09d80e2 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -31,6 +31,7 @@ import { } from './MobileNativeChatSessionOptionPickers' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { mobileNativeChatInputStyles } from './mobile-native-chat-input-styles' +import { keepHeldPressThroughLongPress } from './held-press-long-press' const NO_FILE_PATHS: string[] = [] const NO_ATTACHMENTS: PendingNativeChatImage[] = [] @@ -301,17 +302,26 @@ export function MobileNativeChatComposer({ onPress={dictationMode === 'hold' ? undefined : onMicPress} onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined} onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined} + onLongPress={dictationMode === 'hold' ? keepHeldPressThroughLongPress : undefined} disabled={disabled} > + {/* The icon swaps on press; as the page's touch target, its removal would send + touchend to a detached node and lose the release. */} {micActive ? ( ) : ( - + )} ) : null} diff --git a/mobile/src/session/MobileSessionCommandDock.tsx b/mobile/src/session/MobileSessionCommandDock.tsx index 87b46246572..996f610937a 100644 --- a/mobile/src/session/MobileSessionCommandDock.tsx +++ b/mobile/src/session/MobileSessionCommandDock.tsx @@ -17,6 +17,7 @@ import { } from '../terminal/terminal-keyboard-type' import { MobileTerminalLiveInputStatus } from './MobileTerminalLiveInputStatus' import { MobileTerminalInputActions } from './MobileTerminalInputActions' +import { keepHeldPressThroughLongPress } from './held-press-long-press' import { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers' import { colors } from '../theme/mobile-theme' import { styles } from './mobile-session-styles' @@ -210,6 +211,7 @@ export function MobileSessionCommandDock({ controller }: { controller: MobileSes } void handleAccessoryKey(createTerminalLiveAccessoryInput(key)) }} + onLongPress={key.repeatable ? keepHeldPressThroughLongPress : undefined} accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`} >