orca/mobile/src/browser/MobileBrowserPane.tsx

1676 lines
51 KiB
TypeScript

import { Buffer } from 'buffer'
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode
} from 'react'
import {
ActivityIndicator,
AppState,
Image,
PanResponder,
PixelRatio,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
type GestureResponderEvent,
type PanResponderGestureState
} from 'react-native'
import { ArrowUp, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcSuccess } from '../transport/types'
import type {
BrowserScreencastFrame,
BrowserScreencastFrameMetadata
} from '../transport/browser-screencast-protocol'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import {
MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS,
buildMobileBrowserScreencastRequest
} from './browser-screencast-request'
import {
clampBrowserZoomState,
computeBrowserFrameGeometry,
computeBrowserTouchClickRadiusCss,
mapScreenToBrowserPoint,
readLocalTouchPoint,
type BrowserFrameGeometry,
type BrowserPoint,
type BrowserTouchLayout,
type BrowserZoomState
} from './browser-touch-geometry'
import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url'
import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync'
export type MobileBrowserTab = {
type: 'browser'
id: string
title: string
browserWorkspaceId: string
browserPageId: string | null
url: string
loading: boolean
canGoBack: boolean
canGoForward: boolean
isActive: boolean
}
type MobileBrowserPaneProps = {
client: RpcClient | null
worktreeId: string
tab: MobileBrowserTab
screencastSupported: boolean | null
keyboardLift: number
bottomInset: number
onToast: (message: string, durationMs?: number) => void
}
type FrameLayer = 0 | 1
type PinchGesture = {
distance: number
scale: number
anchorX: number
anchorY: number
}
type PanGesture = {
x: number
y: number
offsetX: number
offsetY: number
}
type BrowserDialogState = {
dialogType: string
message: string
}
const TAP_SLOP = 16
const SCROLL_START_SLOP = 22
const LONG_PRESS_MS = 550
const WHEEL_INTERVAL_MS = 70
const TOUCH_CLICK_RADIUS_DIP = 14
const MIN_ZOOM = 1
const MAX_ZOOM = 3.5
const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 }
const BROWSER_FRAME_CACHE_LIMIT = 4
type BrowserFrameCacheEntry = {
uri: string
metadata: BrowserScreencastFrameMetadata
}
const browserFrameCache = new Map<string, BrowserFrameCacheEntry>()
type BrowserPageParams = {
worktree: string
page: string
}
type PendingWheelCommand = {
base: BrowserPageParams
point: BrowserPoint
gestureId: number
dx: number
dy: number
}
export function MobileBrowserPane({
client,
worktreeId,
tab,
screencastSupported,
keyboardLift,
bottomInset,
onToast
}: MobileBrowserPaneProps) {
const cacheKey = makeBrowserFrameCacheKey(worktreeId, tab.browserPageId)
const cachedInitialFrame = peekCachedBrowserFrame(cacheKey)
const [addressValue, setAddressValue] = useState(displayBrowserUrl(tab.url))
const [addressFocused, setAddressFocused] = useState(false)
const [addressSyncState, setAddressSyncState] = useState({
focused: false,
url: tab.url
})
const [keyboardValue, setKeyboardValue] = useState('')
const [frameUri, setFrameUri] = useState<string | null>(cachedInitialFrame?.uri ?? null)
const [frameMetadata, setFrameMetadata] = useState<BrowserScreencastFrameMetadata | null>(
cachedInitialFrame?.metadata ?? null
)
const [ready, setReady] = useState(cachedInitialFrame !== null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [dialog, setDialog] = useState<BrowserDialogState | null>(null)
const [zoom, setZoom] = useState<BrowserZoomState>(DEFAULT_ZOOM)
const [layout, setLayout] = useState<BrowserTouchLayout | null>(null)
const [appActive, setAppActive] = useState(AppState.currentState === 'active')
const streamGenerationRef = useRef(0)
const layoutRef = useRef<BrowserTouchLayout | null>(null)
const frameMetadataRef = useRef<BrowserScreencastFrameMetadata | null>(
cachedInitialFrame?.metadata ?? null
)
const frameUriRef = useRef<string | null>(cachedInitialFrame?.uri ?? null)
const frameMountedRef = useRef(cachedInitialFrame !== null)
const browserImageRefs = useRef<[Image | null, Image | null]>([null, null])
const browserLayerRefs = useRef<[View | null, View | null]>([null, null])
const pendingFrameLayerRef = useRef<FrameLayer | null>(null)
const visibleFrameLayerRef = useRef<FrameLayer>(0)
const readyRef = useRef(cachedInitialFrame !== null)
const busyRef = useRef(false)
const lastAppliedFrameAtRef = useRef(0)
const pendingThrottledFrameRef = useRef<{
frame: BrowserScreencastFrame
browserPageId: string
} | null>(null)
const frameThrottleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const dialogRef = useRef<BrowserDialogState | null>(null)
const lastStreamPageIdRef = useRef<string | null>(tab.browserPageId)
const startPointRef = useRef<{ x: number; y: number; t: number } | null>(null)
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const rightClickSentRef = useRef(false)
const lastWheelRef = useRef<{ dx: number; dy: number; at: number }>({ dx: 0, dy: 0, at: 0 })
const wheelGestureIdRef = useRef(0)
const pendingWheelCommandRef = useRef<PendingWheelCommand | null>(null)
const wheelCommandInFlightRef = useRef(false)
const zoomRef = useRef<BrowserZoomState>(DEFAULT_ZOOM)
const pinchRef = useRef<PinchGesture | null>(null)
const panRef = useRef<PanGesture | null>(null)
const scrollingRef = useRef(false)
const lastZoomResetUrlRef = useRef(tab.url || 'about:blank')
const clearLongPressTimer = useCallback(() => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
}, [])
const setRootViewRef = useCallback(
(node: View | null) => {
// Why: long-press right-click timers belong to this responder surface;
// clearing from ref cleanup preserves the same unmount boundary.
if (node === null) {
clearLongPressTimer()
}
},
[clearLongPressTimer]
)
const resetBrowserZoomState = useCallback(() => {
clearLongPressTimer()
pinchRef.current = null
panRef.current = null
scrollingRef.current = false
startPointRef.current = null
zoomRef.current = DEFAULT_ZOOM
setZoom(DEFAULT_ZOOM)
}, [clearLongPressTimer])
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextState) => {
const active = nextState === 'active'
if (!active) {
clearCachedBrowserFramesForWorktree(worktreeId)
}
setAppActive(active)
})
return () => {
subscription.remove()
}
}, [worktreeId])
const addressSync = resolveMobileBrowserAddressSync(addressSyncState, {
focused: addressFocused,
url: tab.url
})
if (addressSync.nextState !== addressSyncState) {
setAddressSyncState(addressSync.nextState)
if (addressSync.shouldSyncValue) {
// Why: keep browser stream/goto address updates intact, but avoid a
// stale post-blur paint when the tab URL is the source of truth.
setAddressValue(displayBrowserUrl(tab.url))
}
}
useLayoutEffect(() => {
// Why: gesture and stream handlers need committed values before passive
// Effects flush, without leaking refs from an uncommitted render.
frameMetadataRef.current = frameMetadata
layoutRef.current = layout
dialogRef.current = dialog
zoomRef.current = zoom
}, [dialog, frameMetadata, layout, zoom])
useEffect(() => {
lastZoomResetUrlRef.current = tab.url || 'about:blank'
resetBrowserZoomState()
}, [resetBrowserZoomState, tab.browserPageId, tab.url])
const pageParams = useCallback(() => {
if (!tab.browserPageId) {
return null
}
return {
worktree: `id:${worktreeId}`,
page: tab.browserPageId
}
}, [tab.browserPageId, worktreeId])
const applyFrame = useCallback((frame: BrowserScreencastFrame, browserPageId: string): void => {
if (!browserFrameMetadataEqual(frameMetadataRef.current, frame.metadata)) {
frameMetadataRef.current = frame.metadata
setFrameMetadata(frame.metadata)
}
const nextFrameUri = createBrowserFrameDataUri(frame)
cacheBrowserFrame(browserPageId, { uri: nextFrameUri, metadata: frame.metadata })
if (!frameMountedRef.current) {
frameUriRef.current = nextFrameUri
frameMountedRef.current = true
setFrameUri(nextFrameUri)
updateBrowserImageSource(browserImageRefs.current[0], nextFrameUri)
} else if (pendingFrameLayerRef.current === null) {
// Why: decode the next frame offscreen and keep the previous layer visible
// until onLoad; replacing the visible Image directly flashes black.
const nextLayer: FrameLayer = visibleFrameLayerRef.current === 0 ? 1 : 0
frameUriRef.current = nextFrameUri
pendingFrameLayerRef.current = nextLayer
updateBrowserImageSource(browserImageRefs.current[nextLayer], nextFrameUri)
} else {
// Why: popovers/menus can settle in one final frame while the previous
// offscreen frame is still decoding. Keep the hidden layer pointed at
// the newest frame instead of dropping the final static state.
frameUriRef.current = nextFrameUri
updateBrowserImageSource(browserImageRefs.current[pendingFrameLayerRef.current], nextFrameUri)
}
if (busyRef.current) {
busyRef.current = false
setBusy(false)
}
if (!readyRef.current) {
readyRef.current = true
setReady(true)
}
}, [])
const clearFrameThrottle = useCallback(() => {
pendingThrottledFrameRef.current = null
if (frameThrottleTimerRef.current) {
clearTimeout(frameThrottleTimerRef.current)
frameThrottleTimerRef.current = null
}
}, [])
const applyFrameThrottled = useCallback(
(frame: BrowserScreencastFrame, browserPageId: string): void => {
const now = Date.now()
const elapsed = now - lastAppliedFrameAtRef.current
if (lastAppliedFrameAtRef.current === 0 || elapsed >= MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS) {
clearFrameThrottle()
lastAppliedFrameAtRef.current = now
applyFrame(frame, browserPageId)
return
}
// Why: static UI changes can be the last frame Chromium emits. Coalesce
// throttled frames so the final visible state is applied after the delay.
pendingThrottledFrameRef.current = { frame, browserPageId }
if (frameThrottleTimerRef.current) {
return
}
frameThrottleTimerRef.current = setTimeout(
() => {
frameThrottleTimerRef.current = null
const pending = pendingThrottledFrameRef.current
pendingThrottledFrameRef.current = null
if (!pending) {
return
}
lastAppliedFrameAtRef.current = Date.now()
applyFrame(pending.frame, pending.browserPageId)
},
Math.max(0, MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS - elapsed)
)
},
[applyFrame, clearFrameThrottle]
)
const streamRequest = useMemo(
() => buildMobileBrowserScreencastRequest(layout, PixelRatio.get()),
[layout]
)
const frameGeometry = useMemo(
() => computeBrowserFrameGeometry(layout, frameMetadata),
[frameMetadata, layout]
)
useEffect(() => {
if (!frameGeometry) {
return
}
setZoom((current) => {
const next = clampBrowserZoomState(current, frameGeometry, MIN_ZOOM, MAX_ZOOM)
if (
next.scale === current.scale &&
next.offsetX === current.offsetX &&
next.offsetY === current.offsetY
) {
return current
}
// Why: rotation/layout changes can shrink the legal pan range while the
// current zoom state still points at the previous viewport geometry.
zoomRef.current = next
return next
})
}, [frameGeometry])
useEffect(() => {
streamGenerationRef.current += 1
const generation = streamGenerationRef.current
const samePage = Boolean(tab.browserPageId) && lastStreamPageIdRef.current === tab.browserPageId
lastStreamPageIdRef.current = tab.browserPageId
if (!samePage || !frameUriRef.current) {
const cachedFrame = getCachedBrowserFrame(cacheKey)
if (cachedFrame) {
frameUriRef.current = cachedFrame.uri
frameMountedRef.current = true
frameMetadataRef.current = cachedFrame.metadata
setFrameUri(cachedFrame.uri)
setFrameMetadata(cachedFrame.metadata)
readyRef.current = true
setReady(true)
} else {
frameUriRef.current = null
frameMountedRef.current = false
setFrameUri(null)
setFrameMetadata(null)
frameMetadataRef.current = null
readyRef.current = false
setReady(false)
}
} else {
frameMountedRef.current = true
}
pendingFrameLayerRef.current = null
if (!samePage || !frameUriRef.current) {
visibleFrameLayerRef.current = 0
}
updateBrowserLayerVisibility(browserLayerRefs.current, visibleFrameLayerRef.current)
lastAppliedFrameAtRef.current = 0
clearFrameThrottle()
busyRef.current = false
setDialog(null)
setError(null)
if (
!client ||
screencastSupported !== true ||
!tab.browserPageId ||
!appActive ||
!streamRequest
) {
busyRef.current = false
setBusy(false)
if (screencastSupported === false) {
setError('Update desktop Orca to stream browser tabs on mobile.')
} else if (screencastSupported === null) {
setError('Checking desktop browser streaming support.')
} else if (!tab.browserPageId) {
setError('Browser page is not available yet.')
}
return
}
busyRef.current = true
setBusy(true)
let startupTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
if (streamGenerationRef.current !== generation) return
busyRef.current = false
setBusy(false)
setError('Browser stream timed out.')
}, 15_000)
const clearStartupTimer = (): void => {
if (startupTimer) {
clearTimeout(startupTimer)
startupTimer = null
}
}
const unsubscribe = client.subscribe(
'browser.screencast',
{
worktree: `id:${worktreeId}`,
page: tab.browserPageId,
...streamRequest
},
(payload) => {
if (streamGenerationRef.current !== generation) return
const event = payload as {
type?: string
message?: string
error?: { message?: string }
dialogType?: string
tab?: { url?: string; title?: string; canGoBack?: boolean; canGoForward?: boolean }
}
if (event.type === 'ready') {
clearStartupTimer()
if (!readyRef.current) {
readyRef.current = true
setReady(true)
}
if (busyRef.current) {
busyRef.current = false
setBusy(false)
}
if (typeof event.tab?.url === 'string') {
setAddressValue(displayBrowserUrl(event.tab.url))
if (event.tab.url !== lastZoomResetUrlRef.current) {
lastZoomResetUrlRef.current = event.tab.url
resetBrowserZoomState()
}
}
} else if (event.type === 'end') {
clearStartupTimer()
if (readyRef.current) {
readyRef.current = false
setReady(false)
}
if (busyRef.current) {
busyRef.current = false
setBusy(false)
}
} else if (event.type === 'dialog') {
setDialog({
dialogType: event.dialogType ?? 'alert',
message: event.message ?? 'Browser dialog'
})
} else if (event.type === 'dialogClosed') {
setDialog(null)
} else if (event.type === 'error') {
clearStartupTimer()
if (busyRef.current) {
busyRef.current = false
setBusy(false)
}
const message = event.message ?? event.error?.message ?? 'Browser stream failed.'
if (shouldSurfaceBrowserError(message)) {
if (readyRef.current) {
readyRef.current = false
setReady(false)
}
setError(message)
}
}
},
{
onBinaryFrame: (frame) => {
if (streamGenerationRef.current !== generation) return
clearStartupTimer()
if (cacheKey) {
applyFrameThrottled(frame, cacheKey)
}
}
}
)
return () => {
clearStartupTimer()
clearFrameThrottle()
unsubscribe()
}
}, [
appActive,
applyFrameThrottled,
clearFrameThrottle,
client,
resetBrowserZoomState,
screencastSupported,
streamRequest,
cacheKey,
tab.browserPageId,
worktreeId
])
const sendBrowserRequest = useCallback(
async (
method: string,
params: Record<string, unknown> = {},
opts: { showBusy?: boolean; suppressError?: boolean; timeoutMs?: number } = {}
): Promise<unknown | null> => {
const base = pageParams()
if (!client || !base) {
return null
}
if (opts.showBusy) {
busyRef.current = true
setBusy(true)
}
try {
const response = await client.sendRequest(
method,
{ ...base, ...params },
{ timeoutMs: opts.timeoutMs ?? 15_000 }
)
if (!response.ok) {
throw new Error((response as RpcFailure).error.message)
}
setError(null)
return (response as RpcSuccess).result
} catch (err) {
const message = browserErrorMessage(err, 'Browser command failed')
if (!opts.suppressError && shouldSurfaceBrowserError(message)) {
setError(message)
}
return null
} finally {
if (opts.showBusy) {
busyRef.current = false
setBusy(false)
}
}
},
[client, pageParams]
)
const navigateToAddress = useCallback(async () => {
const url = normalizeBrowserUrl(addressValue)
if (!url) {
setError('Enter a valid URL.')
return
}
const result = (await sendBrowserRequest(
'browser.goto',
{ url },
{ showBusy: true, timeoutMs: 30_000 }
)) as { url?: string } | null
if (typeof result?.url === 'string') {
setAddressValue(displayBrowserUrl(result.url))
lastZoomResetUrlRef.current = result.url
resetBrowserZoomState()
}
}, [addressValue, resetBrowserZoomState, sendBrowserRequest])
const flushPendingWheelCommand = useCallback(() => {
if (wheelCommandInFlightRef.current) {
return
}
const pending = pendingWheelCommandRef.current
if (!pending || !client) {
return
}
pendingWheelCommandRef.current = null
wheelCommandInFlightRef.current = true
void (async () => {
try {
assertRpcOk(
await client.sendRequest('browser.mouseMove', {
...pending.base,
x: pending.point.x,
y: pending.point.y
}),
'Browser pointer move failed'
)
assertRpcOk(
await client.sendRequest('browser.mouseWheel', {
...pending.base,
dx: pending.dx,
dy: pending.dy
}),
'Browser scroll failed'
)
setError(null)
} catch {
// Scroll bursts commonly race page reload/navigation. Avoid replacing
// the live browser with transient command errors like selector_not_found.
} finally {
wheelCommandInFlightRef.current = false
flushPendingWheelCommand()
}
})()
}, [client])
const sendPointerClick = useCallback(
async (point: BrowserPoint, button: 'left' | 'right') => {
const base = pageParams()
if (!client || !base) {
return
}
const clickResult = await sendBrowserRequest(
'browser.mouseClick',
{
x: point.x,
y: point.y,
button,
...(button === 'left'
? {
radius: computeBrowserTouchClickRadiusCss(
layoutRef.current,
frameMetadataRef.current,
zoomRef.current,
TOUCH_CLICK_RADIUS_DIP
)
}
: {})
},
{ suppressError: true, timeoutMs: 5_000 }
)
if (clickResult !== null) {
return
}
try {
assertRpcOk(
await client.sendRequest('browser.mouseMove', { ...base, x: point.x, y: point.y }),
'Browser pointer move failed'
)
assertRpcOk(
await client.sendRequest('browser.mouseDown', { ...base, button }),
'Browser pointer down failed'
)
assertRpcOk(
await client.sendRequest('browser.mouseUp', { ...base, button }),
'Browser pointer up failed'
)
setError(null)
} catch {
// Pointer commands can race page navigation. Keep the stream visible;
// actionable failures still surface through navigation/stream errors.
}
},
[client, pageParams, sendBrowserRequest]
)
const sendWheel = useCallback(
(point: BrowserPoint, screenDx: number, screenDy: number) => {
const base = pageParams()
if (!client || !base) {
return
}
const currentLayout = layoutRef.current
const geometry = computeBrowserFrameGeometry(currentLayout, frameMetadataRef.current)
const localZoom = zoomRef.current.scale
const scale = (geometry?.scale ?? 1) * localZoom
const cssDx = screenDx / scale
const cssDy = screenDy / scale
const delta = { dx: Math.round(-cssDx), dy: Math.round(-cssDy) }
if (Math.abs(delta.dx) < 1 && Math.abs(delta.dy) < 1) {
return
}
const pending = pendingWheelCommandRef.current
pendingWheelCommandRef.current =
pending &&
pending.base.page === base.page &&
pending.gestureId === wheelGestureIdRef.current
? {
base,
point,
gestureId: wheelGestureIdRef.current,
dx: pending.dx + delta.dx,
dy: pending.dy + delta.dy
}
: { base, point, gestureId: wheelGestureIdRef.current, ...delta }
flushPendingWheelCommand()
},
[client, flushPendingWheelCommand, pageParams]
)
const mapTouchPoint = useCallback((locationX: number, locationY: number): BrowserPoint | null => {
return mapScreenToBrowserPoint(
locationX,
locationY,
layoutRef.current,
frameMetadataRef.current,
zoomRef.current
)
}, [])
const handleResponderGrant = useCallback(
(event: GestureResponderEvent) => {
const pinch = createPinchGesture(event, frameGeometry, zoomRef.current)
if (pinch) {
clearLongPressTimer()
pinchRef.current = pinch
panRef.current = null
startPointRef.current = null
return
}
const startPoint = readLocalTouchPoint(event.nativeEvent)
if (!startPoint) {
return
}
startPointRef.current = { x: startPoint.x, y: startPoint.y, t: Date.now() }
rightClickSentRef.current = false
scrollingRef.current = false
wheelGestureIdRef.current += 1
lastWheelRef.current = { dx: 0, dy: 0, at: 0 }
panRef.current =
zoomRef.current.scale > MIN_ZOOM
? {
x: startPoint.x,
y: startPoint.y,
offsetX: zoomRef.current.offsetX,
offsetY: zoomRef.current.offsetY
}
: null
clearLongPressTimer()
longPressTimerRef.current = setTimeout(() => {
const start = startPointRef.current
if (!start) return
const point = mapTouchPoint(start.x, start.y)
if (!point) return
rightClickSentRef.current = true
void sendPointerClick(point, 'right')
onToast('Right click')
}, LONG_PRESS_MS)
},
[clearLongPressTimer, frameGeometry, mapTouchPoint, onToast, sendPointerClick]
)
const handleResponderMove = useCallback(
(event: GestureResponderEvent, gesture: PanResponderGestureState) => {
const startedPinch = pinchRef.current
? null
: createPinchGesture(event, frameGeometry, zoomRef.current)
if (startedPinch) {
clearLongPressTimer()
pinchRef.current = startedPinch
panRef.current = null
startPointRef.current = null
}
const activePinch = pinchRef.current
const nextPinch = activePinch ? updatePinchZoom(event, frameGeometry, activePinch) : null
if (nextPinch) {
clearLongPressTimer()
zoomRef.current = nextPinch
setZoom(nextPinch)
return
}
if (activePinch) {
pinchRef.current = null
}
const moved = Math.hypot(gesture.dx, gesture.dy)
if (moved > TAP_SLOP) {
clearLongPressTimer()
}
const activePan = panRef.current
if (activePan && frameGeometry) {
const currentPoint = readLocalTouchPoint(event.nativeEvent)
if (!currentPoint) {
return
}
if (!scrollingRef.current && moved <= TAP_SLOP) {
return
}
scrollingRef.current = true
startPointRef.current = null
const nextZoom = clampBrowserZoomState(
{
scale: zoomRef.current.scale,
offsetX: activePan.offsetX + currentPoint.x - activePan.x,
offsetY: activePan.offsetY + currentPoint.y - activePan.y
},
frameGeometry,
MIN_ZOOM,
MAX_ZOOM
)
zoomRef.current = nextZoom
setZoom(nextZoom)
return
}
if (!scrollingRef.current) {
if (moved <= SCROLL_START_SLOP) {
return
}
scrollingRef.current = true
startPointRef.current = null
}
const now = Date.now()
if (now - lastWheelRef.current.at < WHEEL_INTERVAL_MS) {
return
}
const deltaX = gesture.dx - lastWheelRef.current.dx
const deltaY = gesture.dy - lastWheelRef.current.dy
if (Math.abs(deltaX) + Math.abs(deltaY) < 8) {
return
}
const currentPoint = readLocalTouchPoint(event.nativeEvent)
if (!currentPoint) {
return
}
const point = mapTouchPoint(currentPoint.x, currentPoint.y)
if (!point) {
return
}
lastWheelRef.current = { dx: gesture.dx, dy: gesture.dy, at: now }
sendWheel(point, deltaX, deltaY)
},
[clearLongPressTimer, frameGeometry, mapTouchPoint, sendWheel]
)
const handleResponderRelease = useCallback(
(event: GestureResponderEvent, gesture: PanResponderGestureState) => {
clearLongPressTimer()
pinchRef.current = null
panRef.current = null
const start = startPointRef.current
startPointRef.current = null
const wasScrolling = scrollingRef.current
scrollingRef.current = false
if (!start || rightClickSentRef.current || wasScrolling) {
return
}
const moved = Math.hypot(gesture.dx, gesture.dy)
if (moved <= TAP_SLOP && Date.now() - start.t < LONG_PRESS_MS) {
// Why: native browser taps resolve at touch-up. Using touch-down makes
// tiny finger drift feel like the click lands left/up of the finger.
const release = readLocalTouchPoint(event.nativeEvent) ?? start
const point = mapTouchPoint(release.x, release.y)
if (point) {
void sendPointerClick(point, 'left')
}
}
},
[clearLongPressTimer, mapTouchPoint, sendPointerClick]
)
const panResponder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => dialogRef.current === null,
onMoveShouldSetPanResponder: () => dialogRef.current === null,
onPanResponderGrant: handleResponderGrant,
onPanResponderMove: handleResponderMove,
onPanResponderRelease: handleResponderRelease,
onPanResponderTerminate: () => {
clearLongPressTimer()
pinchRef.current = null
panRef.current = null
scrollingRef.current = false
startPointRef.current = null
},
onPanResponderTerminationRequest: () => true
}),
[clearLongPressTimer, handleResponderGrant, handleResponderMove, handleResponderRelease]
)
const sendKeyboardText = useCallback(async () => {
const text = keyboardValue
if (!text) {
return
}
setKeyboardValue('')
const result = await sendBrowserRequest(
'browser.keyboardInsertText',
{ text },
{ suppressError: true }
)
if (result !== null) {
onToast('Sent')
} else {
setKeyboardValue(text)
}
}, [keyboardValue, onToast, sendBrowserRequest])
const sendKeypress = useCallback(
async (key: string) => {
await sendBrowserRequest('browser.keypress', { key }, { suppressError: true })
},
[sendBrowserRequest]
)
const sendDialogCommand = useCallback(
async (method: 'browser.dialogAccept' | 'browser.dialogDismiss') => {
setDialog(null)
await sendBrowserRequest(method, {}, { suppressError: true, timeoutMs: 5_000 })
},
[sendBrowserRequest]
)
const setBrowserImageRef = useCallback((layer: FrameLayer, image: Image | null) => {
browserImageRefs.current[layer] = image
const currentFrameUri = frameUriRef.current
if (image && currentFrameUri) {
updateBrowserImageSource(image, currentFrameUri)
}
}, [])
const setBrowserLayerRef = useCallback((layer: FrameLayer, view: View | null) => {
browserLayerRefs.current[layer] = view
updateBrowserLayerVisibility(browserLayerRefs.current, visibleFrameLayerRef.current)
}, [])
const setBrowserLayer0Ref = useCallback(
(view: View | null) => setBrowserLayerRef(0, view),
[setBrowserLayerRef]
)
const setBrowserLayer1Ref = useCallback(
(view: View | null) => setBrowserLayerRef(1, view),
[setBrowserLayerRef]
)
const setBrowserImageLayer0Ref = useCallback(
(image: Image | null) => setBrowserImageRef(0, image),
[setBrowserImageRef]
)
const setBrowserImageLayer1Ref = useCallback(
(image: Image | null) => setBrowserImageRef(1, image),
[setBrowserImageRef]
)
const handleBrowserImageLoad = useCallback((layer: FrameLayer) => {
if (pendingFrameLayerRef.current !== layer) {
return
}
pendingFrameLayerRef.current = null
visibleFrameLayerRef.current = layer
updateBrowserLayerVisibility(browserLayerRefs.current, layer)
}, [])
const handleBrowserImageLayer0Load = useCallback(
() => handleBrowserImageLoad(0),
[handleBrowserImageLoad]
)
const handleBrowserImageLayer1Load = useCallback(
() => handleBrowserImageLoad(1),
[handleBrowserImageLoad]
)
const handleBrowserImageError = useCallback((layer: FrameLayer) => {
if (pendingFrameLayerRef.current === layer) {
pendingFrameLayerRef.current = null
}
}, [])
const handleBrowserImageLayer0Error = useCallback(
() => handleBrowserImageError(0),
[handleBrowserImageError]
)
const handleBrowserImageLayer1Error = useCallback(
() => handleBrowserImageError(1),
[handleBrowserImageError]
)
const controlsDisabled = !client || !tab.browserPageId || screencastSupported !== true
const addressSelection = useMemo(
() => (addressFocused ? undefined : { start: 0, end: 0 }),
[addressFocused]
)
const goBack = useCallback(() => {
if (controlsDisabled || !tab.canGoBack) {
return
}
void sendBrowserRequest('browser.back', {}, { suppressError: true })
}, [controlsDisabled, sendBrowserRequest, tab.canGoBack])
const goForward = useCallback(() => {
if (controlsDisabled || !tab.canGoForward) {
return
}
void sendBrowserRequest('browser.forward', {}, { suppressError: true })
}, [controlsDisabled, sendBrowserRequest, tab.canGoForward])
const reloadPage = useCallback(() => {
if (controlsDisabled) {
return
}
void sendBrowserRequest('browser.reload', {}, { suppressError: true })
}, [controlsDisabled, sendBrowserRequest])
const renderedFrameSource =
frameUriRef.current || frameUri ? { uri: frameUriRef.current ?? frameUri! } : null
const frameLayerStyle = useCallback((layer: FrameLayer) => {
return [
styles.browserImageLayer,
visibleFrameLayerRef.current !== layer && styles.browserImageLayerHidden
]
}, [])
const browserLayerRef = useCallback(
(layer: FrameLayer) => (layer === 0 ? setBrowserLayer0Ref : setBrowserLayer1Ref),
[setBrowserLayer0Ref, setBrowserLayer1Ref]
)
const frameLayerRef = useCallback(
(layer: FrameLayer) => (layer === 0 ? setBrowserImageLayer0Ref : setBrowserImageLayer1Ref),
[setBrowserImageLayer0Ref, setBrowserImageLayer1Ref]
)
const frameLayerLoadHandler = useCallback(
(layer: FrameLayer) =>
layer === 0 ? handleBrowserImageLayer0Load : handleBrowserImageLayer1Load,
[handleBrowserImageLayer0Load, handleBrowserImageLayer1Load]
)
const frameLayerErrorHandler = useCallback(
(layer: FrameLayer) =>
layer === 0 ? handleBrowserImageLayer0Error : handleBrowserImageLayer1Error,
[handleBrowserImageLayer0Error, handleBrowserImageLayer1Error]
)
return (
<View ref={setRootViewRef} style={styles.root}>
<View style={styles.toolbar}>
<ToolbarIconButton
disabled={controlsDisabled || !tab.canGoBack}
label="Back"
onPress={goBack}
>
<ChevronLeft size={15} color={buttonColor(!controlsDisabled && tab.canGoBack)} />
</ToolbarIconButton>
<ToolbarIconButton
disabled={controlsDisabled || !tab.canGoForward}
label="Forward"
onPress={goForward}
>
<ChevronRight size={15} color={buttonColor(!controlsDisabled && tab.canGoForward)} />
</ToolbarIconButton>
<ToolbarIconButton disabled={controlsDisabled} label="Reload" onPress={reloadPage}>
<RefreshCw size={15} color={buttonColor(!controlsDisabled)} />
</ToolbarIconButton>
<TextInput
style={styles.addressInput}
value={addressValue}
onChangeText={setAddressValue}
onFocus={() => setAddressFocused(true)}
onBlur={() => setAddressFocused(false)}
onSubmitEditing={() => void navigateToAddress()}
selectTextOnFocus
selection={addressSelection}
autoCapitalize="none"
autoCorrect={false}
keyboardType={Platform.OS === 'ios' ? 'url' : 'default'}
numberOfLines={1}
returnKeyType="go"
placeholder="URL"
placeholderTextColor={colors.textMuted}
editable={!controlsDisabled}
/>
</View>
<View
style={styles.viewport}
onLayout={(event) => {
const next = {
width: event.nativeEvent.layout.width,
height: event.nativeEvent.layout.height
}
const current = layoutRef.current
if (current && current.width === next.width && current.height === next.height) {
return
}
layoutRef.current = next
setLayout(next)
}}
{...panResponder.panHandlers}
>
{renderedFrameSource ? (
<View style={styles.browserImageHost}>
{frameGeometry ? (
<View
pointerEvents="none"
style={[
styles.browserZoomOffset,
{
width: frameGeometry.renderedWidth,
height: frameGeometry.renderedHeight,
transform: [{ translateX: zoom.offsetX }, { translateY: zoom.offsetY }]
}
]}
>
<View
style={[
styles.browserFrameBox,
{
width: frameGeometry.renderedWidth,
height: frameGeometry.renderedHeight,
transform: [{ scale: zoom.scale }]
}
]}
>
{([0, 1] as const).map((layer) => (
<View
key={layer}
ref={browserLayerRef(layer)}
pointerEvents="none"
style={frameLayerStyle(layer)}
>
<Image
ref={frameLayerRef(layer)}
source={renderedFrameSource}
resizeMode="stretch"
fadeDuration={0}
onLoad={frameLayerLoadHandler(layer)}
onError={frameLayerErrorHandler(layer)}
style={[
styles.browserImage,
{
width: frameGeometry.renderedWidth,
height: frameGeometry.renderedHeight
}
]}
/>
</View>
))}
</View>
</View>
) : (
([0, 1] as const).map((layer) => (
<View
key={layer}
ref={browserLayerRef(layer)}
pointerEvents="none"
style={frameLayerStyle(layer)}
>
<Image
ref={frameLayerRef(layer)}
source={renderedFrameSource}
resizeMode="contain"
fadeDuration={0}
onLoad={frameLayerLoadHandler(layer)}
onError={frameLayerErrorHandler(layer)}
style={styles.browserImageFill}
/>
</View>
))
)}
</View>
) : null}
{!renderedFrameSource || busy || error ? (
<View pointerEvents="none" style={styles.overlay}>
{busy || (!ready && !error) ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : null}
{error ? <Text style={styles.errorText}>{error}</Text> : null}
</View>
) : null}
{dialog ? (
<View style={styles.dialogOverlay}>
<View style={styles.dialogCard}>
<Text style={styles.dialogTitle}>Browser Dialog</Text>
<Text style={styles.dialogMessage}>{dialog.message}</Text>
<View style={styles.dialogActions}>
{dialog.dialogType !== 'alert' ? (
<Pressable
style={({ pressed }) => [
styles.dialogButton,
pressed && styles.dialogButtonPressed
]}
onPress={() => void sendDialogCommand('browser.dialogDismiss')}
>
<Text style={styles.dialogButtonText}>Cancel</Text>
</Pressable>
) : null}
<Pressable
style={({ pressed }) => [
styles.dialogButton,
styles.dialogButtonPrimary,
pressed && styles.dialogButtonPressed
]}
onPress={() => void sendDialogCommand('browser.dialogAccept')}
>
<Text style={[styles.dialogButtonText, styles.dialogButtonPrimaryText]}>OK</Text>
</Pressable>
</View>
</View>
</View>
) : null}
</View>
<View
style={[
styles.keyboardDock,
{ paddingBottom: bottomInset, transform: [{ translateY: -keyboardLift }] }
]}
>
<View style={styles.keyRow}>
{['Enter', 'Backspace', 'Tab', 'Escape'].map((key) => (
<Pressable
key={key}
style={({ pressed }) => [
styles.keyButton,
pressed && styles.keyButtonPressed,
controlsDisabled && styles.disabled
]}
disabled={controlsDisabled}
onPress={() => void sendKeypress(key)}
>
<Text style={[styles.keyButtonText, controlsDisabled && styles.disabledText]}>
{key === 'Backspace' ? '⌫' : key === 'Escape' ? 'Esc' : key}
</Text>
</Pressable>
))}
</View>
<View style={styles.inputRow}>
<TextInput
style={styles.keyboardInput}
value={keyboardValue}
onChangeText={setKeyboardValue}
placeholder="Type on page…"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
editable={!controlsDisabled}
onSubmitEditing={() => void sendKeyboardText()}
/>
<Pressable
style={[styles.sendButton, (controlsDisabled || !keyboardValue) && styles.disabled]}
disabled={controlsDisabled || !keyboardValue}
onPress={() => void sendKeyboardText()}
accessibilityLabel="Send text to browser"
>
<ArrowUp size={18} color={buttonColor(!controlsDisabled && !!keyboardValue)} />
</Pressable>
</View>
</View>
</View>
)
}
function ToolbarIconButton({
children,
disabled,
label,
onPress
}: {
children: ReactNode
disabled?: boolean
label: string
onPress: () => void
}) {
return (
<Pressable
style={({ pressed }) => [
styles.toolbarIconButton,
pressed && !disabled && styles.toolbarIconButtonPressed,
disabled && styles.disabled
]}
disabled={disabled}
onPress={onPress}
accessibilityLabel={label}
>
{children}
</Pressable>
)
}
function buttonColor(enabled: boolean): string {
return enabled ? colors.textSecondary : colors.textMuted
}
function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
}
function makeBrowserFrameCacheKey(worktreeId: string, browserPageId: string | null): string | null {
return browserPageId ? `${worktreeId}:${browserPageId}` : null
}
function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
const prefix = `${worktreeId}:`
for (const key of browserFrameCache.keys()) {
if (key.startsWith(prefix)) {
browserFrameCache.delete(key)
}
}
}
function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
if (!cacheKey) {
return null
}
const cached = browserFrameCache.get(cacheKey)
if (!cached) {
return null
}
browserFrameCache.delete(cacheKey)
browserFrameCache.set(cacheKey, cached)
return cached
}
function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
}
function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
if (!cacheKey) {
return
}
browserFrameCache.delete(cacheKey)
browserFrameCache.set(cacheKey, entry)
while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
const oldestKey = browserFrameCache.keys().next().value
if (typeof oldestKey !== 'string') {
break
}
browserFrameCache.delete(oldestKey)
}
}
function updateBrowserLayerVisibility(
layers: [View | null, View | null],
visible: FrameLayer
): void {
for (const [index, layer] of layers.entries()) {
layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } })
}
}
function updateBrowserImageSource(image: Image | null, uri: string): void {
// Why: browser frames are large strings; mutating only the native Image
// source avoids re-rendering the whole tab view for every streamed frame.
const source = [{ uri }]
image?.setNativeProps({ source, src: source })
}
function assertRpcOk(
response: RpcSuccess | RpcFailure,
fallbackMessage: string
): asserts response is RpcSuccess {
if (!response.ok) {
throw new Error(response.error.message || fallbackMessage)
}
}
function browserFrameMetadataEqual(
a: BrowserScreencastFrameMetadata | null,
b: BrowserScreencastFrameMetadata
): boolean {
return (
a?.deviceWidth === b.deviceWidth &&
a?.deviceHeight === b.deviceHeight &&
a?.pageScaleFactor === b.pageScaleFactor
)
}
function browserErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback
}
function shouldSurfaceBrowserError(message: string): boolean {
const normalized = message.toLowerCase()
// Why: selector_not_found can be emitted by in-flight page automation while
// the browser is still usable; replacing the frame with it feels like a crash.
return !normalized.includes('selector_not_found') && !normalized.includes('selector not found')
}
function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null {
const touches = event.nativeEvent.touches
if (!touches || touches.length < 2) {
return null
}
const a = readLocalTouchPoint(touches[0])
const b = readLocalTouchPoint(touches[1])
return a && b ? { a, b } : null
}
function pointDistance(a: BrowserPoint, b: BrowserPoint): number {
return Math.hypot(a.x - b.x, a.y - b.y)
}
function createPinchGesture(
event: GestureResponderEvent,
geometry: BrowserFrameGeometry | null,
zoom: BrowserZoomState
): PinchGesture | null {
if (!geometry) {
return null
}
const pair = touchPair(event)
if (!pair) {
return null
}
const distance = pointDistance(pair.a, pair.b)
if (distance < 8) {
return null
}
const centerX = (pair.a.x + pair.b.x) / 2
const centerY = (pair.a.y + pair.b.y) / 2
const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX
const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY
return {
distance,
scale: zoom.scale,
anchorX: (centerX - frameCenterX) / zoom.scale,
anchorY: (centerY - frameCenterY) / zoom.scale
}
}
function updatePinchZoom(
event: GestureResponderEvent,
geometry: BrowserFrameGeometry | null,
pinch: PinchGesture
): BrowserZoomState | null {
if (!geometry) {
return null
}
const pair = touchPair(event)
if (!pair) {
return null
}
const nextScale = Math.min(
MAX_ZOOM,
Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance)
)
const centerX = (pair.a.x + pair.b.x) / 2
const centerY = (pair.a.y + pair.b.y) / 2
const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2
const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2
return clampBrowserZoomState(
{
scale: nextScale,
offsetX: centerX - baseCenterX - pinch.anchorX * nextScale,
offsetY: centerY - baseCenterY - pinch.anchorY * nextScale
},
geometry,
MIN_ZOOM,
MAX_ZOOM
)
}
const styles = StyleSheet.create({
root: {
flex: 1,
minHeight: 0,
backgroundColor: colors.bgBase
},
toolbar: {
minHeight: 32,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
toolbarIconButton: {
width: 26,
height: 26,
borderRadius: radii.button,
alignItems: 'center',
justifyContent: 'center'
},
toolbarIconButtonPressed: {
backgroundColor: colors.bgRaised
},
addressInput: {
flex: 1,
minWidth: 0,
height: 28,
borderRadius: radii.input,
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
paddingHorizontal: spacing.sm,
paddingVertical: 0,
fontSize: 12,
lineHeight: 16,
includeFontPadding: false,
textAlignVertical: 'center',
fontFamily: typography.monoFamily
},
viewport: {
flex: 1,
minHeight: 0,
overflow: 'hidden',
backgroundColor: colors.bgBase
},
browserImageHost: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
},
browserImageFill: {
width: '100%',
height: '100%'
},
browserImageLayer: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center'
},
browserImageLayerHidden: {
opacity: 0
},
browserZoomOffset: {
alignItems: 'center',
justifyContent: 'center'
},
browserFrameBox: {
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
},
browserImage: {
backgroundColor: colors.bgBase
},
overlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
gap: spacing.sm,
backgroundColor: 'rgba(13, 15, 24, 0.2)'
},
errorText: {
color: colors.textPrimary,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: 13,
textAlign: 'center',
overflow: 'hidden'
},
dialogOverlay: {
...StyleSheet.absoluteFillObject,
zIndex: 30,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
backgroundColor: 'rgba(13, 15, 24, 0.5)'
},
dialogCard: {
width: '100%',
maxWidth: 360,
borderRadius: radii.card,
borderWidth: 1,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgPanel,
padding: spacing.lg
},
dialogTitle: {
color: colors.textPrimary,
fontSize: 16,
fontWeight: '600'
},
dialogMessage: {
color: colors.textSecondary,
fontSize: typography.bodySize,
lineHeight: 20,
marginTop: spacing.sm
},
dialogActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
marginTop: spacing.lg
},
dialogButton: {
minHeight: 34,
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.md,
alignItems: 'center',
justifyContent: 'center'
},
dialogButtonPrimary: {
backgroundColor: colors.textPrimary
},
dialogButtonPressed: {
opacity: 0.75
},
dialogButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '600'
},
dialogButtonPrimaryText: {
color: colors.bgBase
},
keyboardDock: {
zIndex: 20,
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
keyRow: {
flexDirection: 'row',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingTop: spacing.xs
},
keyButton: {
minHeight: 30,
minWidth: 42,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.sm
},
keyButtonPressed: {
backgroundColor: colors.borderSubtle
},
keyButtonText: {
color: colors.textSecondary,
fontSize: 12,
fontFamily: typography.monoFamily
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingTop: spacing.xs,
paddingBottom: spacing.xs + 2
},
keyboardInput: {
flex: 1,
height: 34,
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
fontSize: 14,
fontFamily: typography.monoFamily,
marginRight: spacing.sm
},
sendButton: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised
},
disabled: {
opacity: 0.35
},
disabledText: {
color: colors.textMuted
}
})