From bdabb09a07af4eca7d50314e9dcbe25e77017fa6 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 27 Aug 2026 17:50:11 -0700
Subject: [PATCH] Extract mobile host and browser helpers (#16779)
---
mobile/app/h/[hostId]/index.tsx | 312 +-------------
mobile/src/browser/MobileBrowserPane.tsx | 395 +-----------------
.../src/browser/mobile-browser-frame-state.ts | 210 ++++++++++
.../src/browser/mobile-browser-pane-styles.ts | 171 ++++++++
.../host-screen/host-screen-primary-styles.ts | 175 ++++++++
.../host-screen-secondary-styles.ts | 139 ++++++
mobile/src/host-screen/host-screen-styles.ts | 7 +
7 files changed, 728 insertions(+), 681 deletions(-)
create mode 100644 mobile/src/browser/mobile-browser-frame-state.ts
create mode 100644 mobile/src/browser/mobile-browser-pane-styles.ts
create mode 100644 mobile/src/host-screen/host-screen-primary-styles.ts
create mode 100644 mobile/src/host-screen/host-screen-secondary-styles.ts
create mode 100644 mobile/src/host-screen/host-screen-styles.ts
diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx
index 3400fd3694b..b211f5cc92f 100644
--- a/mobile/app/h/[hostId]/index.tsx
+++ b/mobile/app/h/[hostId]/index.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
-import { View, Text, StyleSheet, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
+import { View, Text, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
import {
@@ -69,8 +69,9 @@ import { MobileSearchField } from '../../../src/components/MobileSearchField'
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
import { setCachedRepos } from '../../../src/cache/repo-cache'
-import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
+import { colors, spacing } from '../../../src/theme/mobile-theme'
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
+import { hostScreenStyles as styles } from '../../../src/host-screen/host-screen-styles'
import { leaveHostRoute } from '../../../src/host-route-exit'
import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
import {
@@ -1410,310 +1411,3 @@ export default function HostWorktreeRoute() {
function ListSeparator() {
return
}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: colors.bgBase
- },
- topChrome: {
- backgroundColor: colors.bgPanel,
- borderBottomWidth: 1,
- borderBottomColor: colors.borderSubtle
- },
- statusBar: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- minHeight: 34,
- paddingTop: spacing.xs,
- paddingHorizontal: spacing.lg
- },
- backButton: {
- width: 32,
- height: 32,
- alignItems: 'center',
- justifyContent: 'center',
- marginRight: spacing.xs
- },
- sidebarCollapseButton: {
- width: 24,
- height: 24,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radii.button,
- marginLeft: spacing.xs
- },
- hostIdentity: {
- flex: 1,
- flexDirection: 'row',
- alignItems: 'center',
- minWidth: 0,
- marginRight: spacing.md
- },
- hostNameText: {
- flex: 1,
- fontSize: 15,
- fontWeight: '600',
- color: colors.textPrimary
- },
- reconnectButton: {
- paddingVertical: 4,
- paddingHorizontal: spacing.sm,
- borderRadius: radii.button,
- backgroundColor: colors.bgPanel,
- borderWidth: 1,
- borderColor: colors.borderSubtle
- },
- reconnectButtonText: {
- color: colors.textPrimary,
- fontSize: typography.metaSize,
- fontWeight: '600'
- },
- toolbar: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingVertical: spacing.xs + 2,
- paddingHorizontal: spacing.md,
- gap: spacing.sm,
- borderBottomWidth: 1,
- borderBottomColor: colors.borderSubtle
- },
- embeddedToolbar: {
- paddingVertical: spacing.xs + 2,
- paddingHorizontal: spacing.sm,
- gap: spacing.xs,
- borderBottomWidth: 1,
- borderBottomColor: colors.borderSubtle
- },
- embeddedToolbarRow: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm
- },
- embeddedFilterChip: {
- flex: 1,
- minWidth: 0,
- height: 30,
- justifyContent: 'center',
- paddingHorizontal: spacing.xs,
- paddingVertical: 0
- },
- embeddedModeButton: {
- flex: 1,
- minWidth: 0,
- height: 30,
- justifyContent: 'center',
- paddingHorizontal: spacing.xs,
- paddingVertical: 0
- },
- filterChip: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 4,
- paddingHorizontal: spacing.sm + 2,
- paddingVertical: spacing.xs,
- borderRadius: 12,
- borderWidth: 1,
- borderColor: colors.borderSubtle
- },
- filterChipActive: {
- borderColor: colors.textSecondary,
- backgroundColor: colors.bgRaised
- },
- filterChipText: {
- fontSize: 12,
- color: colors.textSecondary
- },
- filterChipTextActive: {
- color: colors.textPrimary
- },
- modeButton: {
- flexDirection: 'row',
- alignItems: 'center',
- flexShrink: 1,
- minWidth: 0,
- gap: 4,
- paddingHorizontal: spacing.sm,
- paddingVertical: spacing.xs
- },
- sortLabel: {
- flexShrink: 1,
- minWidth: 0,
- fontSize: 12,
- color: colors.textSecondary
- },
- toolbarSpacer: {
- flex: 1
- },
- floatingWorkspaceHeaderButton: {
- width: 32,
- height: 32,
- alignItems: 'center',
- justifyContent: 'center',
- marginLeft: spacing.xs
- },
- embeddedToolbarIconButton: {
- flex: 1,
- height: 28,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radii.button
- },
- toolbarIconDisabled: {
- opacity: 0.6
- },
- searchToggle: {
- padding: spacing.xs
- },
- searchBar: {
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm,
- borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: colors.borderSubtle,
- backgroundColor: colors.bgPanel
- },
- centered: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center'
- },
- emptyText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize
- },
- errorText: {
- color: colors.statusRed,
- fontSize: typography.bodySize
- },
- list: {
- paddingBottom: spacing.lg
- },
- sectionHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: spacing.lg,
- paddingTop: spacing.md,
- paddingBottom: spacing.xs
- },
- sectionIcon: {
- marginRight: spacing.xs
- },
- sectionRepoIcon: {
- marginRight: spacing.xs
- },
- sectionTitle: {
- fontSize: 11,
- fontWeight: '600',
- color: colors.textMuted,
- textTransform: 'uppercase',
- letterSpacing: 0.5
- },
- sectionCount: {
- fontSize: 11,
- color: colors.textMuted,
- marginLeft: spacing.xs
- },
- separator: {
- height: 1,
- backgroundColor: colors.borderSubtle,
- marginLeft: spacing.lg + 24,
- marginRight: spacing.lg
- },
- filterModalHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- paddingHorizontal: spacing.xs,
- marginBottom: spacing.md
- },
- filterModalTitle: {
- fontSize: 15,
- fontWeight: '600',
- color: colors.textPrimary
- },
- clearFiltersText: {
- fontSize: 13,
- color: colors.textSecondary
- },
- filterSectionLabel: {
- fontSize: 11,
- fontWeight: '600',
- color: colors.textMuted,
- textTransform: 'uppercase',
- letterSpacing: 0.5,
- marginBottom: spacing.xs,
- paddingHorizontal: spacing.xs
- },
- filterGroup: {
- backgroundColor: colors.bgPanel,
- borderRadius: 12,
- overflow: 'hidden',
- marginBottom: spacing.md
- },
- filterRow: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingVertical: spacing.md,
- paddingHorizontal: spacing.md + 2,
- gap: spacing.sm
- },
- filterRowText: {
- flex: 1,
- fontSize: typography.bodySize,
- color: colors.textPrimary
- },
- filterSeparator: {
- height: StyleSheet.hairlineWidth,
- backgroundColor: colors.borderSubtle,
- marginHorizontal: spacing.md
- },
- filterRepoDot: {
- width: 8,
- height: 8,
- borderRadius: 4
- },
- confirmContent: {
- paddingBottom: spacing.lg
- },
- confirmTitle: {
- fontSize: 16,
- fontWeight: '700',
- color: colors.textPrimary
- },
- confirmMessage: {
- fontSize: typography.bodySize,
- color: colors.textSecondary,
- marginTop: spacing.xs,
- lineHeight: 20
- },
- confirmButtons: {
- flexDirection: 'row',
- gap: spacing.sm
- },
- confirmBtn: {
- flex: 1,
- paddingVertical: spacing.sm + 2,
- borderRadius: 10,
- alignItems: 'center'
- },
- confirmBtnCancel: {
- backgroundColor: colors.bgPanel
- },
- confirmBtnDestructive: {
- backgroundColor: colors.statusRed
- },
- confirmBtnPressed: {
- opacity: 0.7
- },
- confirmBtnCancelText: {
- fontSize: typography.bodySize,
- fontWeight: '600',
- color: colors.textSecondary
- },
- confirmBtnDestructiveText: {
- fontSize: typography.bodySize,
- fontWeight: '600',
- color: '#fff'
- }
-})
diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx
index 6397f69c549..f823f309630 100644
--- a/mobile/src/browser/MobileBrowserPane.tsx
+++ b/mobile/src/browser/MobileBrowserPane.tsx
@@ -1,7 +1,4 @@
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */
-// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
-// can't resolve Node's builtin in a React Native bundle.
-import { Buffer } from 'buffer'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
@@ -10,7 +7,6 @@ import {
PanResponder,
PixelRatio,
Pressable,
- StyleSheet,
Text,
TextInput,
View,
@@ -24,7 +20,7 @@ import type {
BrowserScreencastFrame,
BrowserScreencastFrameMetadata
} from '../transport/browser-screencast-protocol'
-import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import { colors } from '../theme/mobile-theme'
import {
MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS,
buildMobileBrowserScreencastRequest,
@@ -47,11 +43,32 @@ import {
computeBrowserTouchClickRadiusCss,
mapScreenToBrowserPoint,
readLocalTouchPoint,
- type BrowserFrameGeometry,
type BrowserPoint,
type BrowserTouchLayout,
type BrowserZoomState
} from './browser-touch-geometry'
+import {
+ MAX_ZOOM,
+ MIN_ZOOM,
+ assertRpcOk,
+ browserErrorMessage,
+ browserFrameMetadataEqual,
+ buttonColor,
+ cacheBrowserFrame,
+ clearCachedBrowserFramesForWorktree,
+ createBrowserFrameDataUri,
+ createPinchGesture,
+ getCachedBrowserFrame,
+ makeBrowserFrameCacheKey,
+ peekCachedBrowserFrame,
+ shouldSurfaceBrowserError,
+ updateBrowserImageSource,
+ updateBrowserLayerVisibility,
+ updatePinchZoom,
+ type FrameLayer,
+ type PinchGesture
+} from './mobile-browser-frame-state'
+import { mobileBrowserPaneStyles as styles } from './mobile-browser-pane-styles'
import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url'
import { MobileBrowserAddressField } from './MobileBrowserAddressField'
import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync'
@@ -79,15 +96,6 @@ type MobileBrowserPaneProps = {
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
@@ -105,17 +113,7 @@ 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()
type BrowserPageParams = {
worktree: string
@@ -1268,350 +1266,3 @@ export function MobileBrowserPane({
)
}
-
-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,
- viewMode: MobileBrowserViewMode
-): string | null {
- return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : 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
- },
- 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
- },
- 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
- }
-})
diff --git a/mobile/src/browser/mobile-browser-frame-state.ts b/mobile/src/browser/mobile-browser-frame-state.ts
new file mode 100644
index 00000000000..9609c2ffda4
--- /dev/null
+++ b/mobile/src/browser/mobile-browser-frame-state.ts
@@ -0,0 +1,210 @@
+import { Buffer } from 'buffer'
+import type { GestureResponderEvent, Image, View } from 'react-native'
+import type { RpcFailure, RpcSuccess } from '../transport/types'
+import type {
+ BrowserScreencastFrame,
+ BrowserScreencastFrameMetadata
+} from '../transport/browser-screencast-protocol'
+import { colors } from '../theme/mobile-theme'
+import {
+ clampBrowserZoomState,
+ readLocalTouchPoint,
+ type BrowserFrameGeometry,
+ type BrowserPoint,
+ type BrowserZoomState
+} from './browser-touch-geometry'
+import type { MobileBrowserViewMode } from './browser-screencast-request'
+
+export type FrameLayer = 0 | 1
+export type PinchGesture = {
+ distance: number
+ scale: number
+ anchorX: number
+ anchorY: number
+}
+export type BrowserFrameCacheEntry = {
+ uri: string
+ metadata: BrowserScreencastFrameMetadata
+}
+export const MIN_ZOOM = 1
+export const MAX_ZOOM = 3.5
+const BROWSER_FRAME_CACHE_LIMIT = 4
+const browserFrameCache = new Map()
+
+export function buttonColor(enabled: boolean): string {
+ return enabled ? colors.textSecondary : colors.textMuted
+}
+
+export function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
+ return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
+}
+
+export function makeBrowserFrameCacheKey(
+ worktreeId: string,
+ browserPageId: string | null,
+ viewMode: MobileBrowserViewMode
+): string | null {
+ return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
+}
+
+export function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
+ const prefix = `${worktreeId}:`
+ for (const key of browserFrameCache.keys()) {
+ if (key.startsWith(prefix)) {
+ browserFrameCache.delete(key)
+ }
+ }
+}
+
+export 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
+}
+
+export function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
+ return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
+}
+
+export 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)
+ }
+}
+
+export 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 } })
+ }
+}
+
+export 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 })
+}
+
+export function assertRpcOk(
+ response: RpcSuccess | RpcFailure,
+ fallbackMessage: string
+): asserts response is RpcSuccess {
+ if (!response.ok) {
+ throw new Error(response.error.message || fallbackMessage)
+ }
+}
+
+export function browserFrameMetadataEqual(
+ a: BrowserScreencastFrameMetadata | null,
+ b: BrowserScreencastFrameMetadata
+): boolean {
+ return (
+ a?.deviceWidth === b.deviceWidth &&
+ a?.deviceHeight === b.deviceHeight &&
+ a?.pageScaleFactor === b.pageScaleFactor
+ )
+}
+
+export function browserErrorMessage(error: unknown, fallback: string): string {
+ return error instanceof Error && error.message ? error.message : fallback
+}
+
+export 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)
+}
+
+export 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
+ }
+}
+
+export 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
+ )
+}
diff --git a/mobile/src/browser/mobile-browser-pane-styles.ts b/mobile/src/browser/mobile-browser-pane-styles.ts
new file mode 100644
index 00000000000..b41428516ba
--- /dev/null
+++ b/mobile/src/browser/mobile-browser-pane-styles.ts
@@ -0,0 +1,171 @@
+import { StyleSheet } from 'react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+
+export const mobileBrowserPaneStyles = 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
+ },
+ 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
+ },
+ 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
+ }
+})
diff --git a/mobile/src/host-screen/host-screen-primary-styles.ts b/mobile/src/host-screen/host-screen-primary-styles.ts
new file mode 100644
index 00000000000..c499ad2afe6
--- /dev/null
+++ b/mobile/src/host-screen/host-screen-primary-styles.ts
@@ -0,0 +1,175 @@
+import { StyleSheet } from 'react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+
+export const hostScreenPrimaryStyles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bgBase
+ },
+ topChrome: {
+ backgroundColor: colors.bgPanel,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderSubtle
+ },
+ statusBar: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ minHeight: 34,
+ paddingTop: spacing.xs,
+ paddingHorizontal: spacing.lg
+ },
+ backButton: {
+ width: 32,
+ height: 32,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: spacing.xs
+ },
+ sidebarCollapseButton: {
+ width: 24,
+ height: 24,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radii.button,
+ marginLeft: spacing.xs
+ },
+ hostIdentity: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ minWidth: 0,
+ marginRight: spacing.md
+ },
+ hostNameText: {
+ flex: 1,
+ fontSize: 15,
+ fontWeight: '600',
+ color: colors.textPrimary
+ },
+ reconnectButton: {
+ paddingVertical: 4,
+ paddingHorizontal: spacing.sm,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle
+ },
+ reconnectButtonText: {
+ color: colors.textPrimary,
+ fontSize: typography.metaSize,
+ fontWeight: '600'
+ },
+ toolbar: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: spacing.xs + 2,
+ paddingHorizontal: spacing.md,
+ gap: spacing.sm,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderSubtle
+ },
+ embeddedToolbar: {
+ paddingVertical: spacing.xs + 2,
+ paddingHorizontal: spacing.sm,
+ gap: spacing.xs,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderSubtle
+ },
+ embeddedToolbarRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm
+ },
+ embeddedFilterChip: {
+ flex: 1,
+ minWidth: 0,
+ height: 30,
+ justifyContent: 'center',
+ paddingHorizontal: spacing.xs,
+ paddingVertical: 0
+ },
+ embeddedModeButton: {
+ flex: 1,
+ minWidth: 0,
+ height: 30,
+ justifyContent: 'center',
+ paddingHorizontal: spacing.xs,
+ paddingVertical: 0
+ },
+ filterChip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ paddingHorizontal: spacing.sm + 2,
+ paddingVertical: spacing.xs,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle
+ },
+ filterChipActive: {
+ borderColor: colors.textSecondary,
+ backgroundColor: colors.bgRaised
+ },
+ filterChipText: {
+ fontSize: 12,
+ color: colors.textSecondary
+ },
+ filterChipTextActive: {
+ color: colors.textPrimary
+ },
+ modeButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ flexShrink: 1,
+ minWidth: 0,
+ gap: 4,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: spacing.xs
+ },
+ sortLabel: {
+ flexShrink: 1,
+ minWidth: 0,
+ fontSize: 12,
+ color: colors.textSecondary
+ },
+ toolbarSpacer: {
+ flex: 1
+ },
+ floatingWorkspaceHeaderButton: {
+ width: 32,
+ height: 32,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginLeft: spacing.xs
+ },
+ embeddedToolbarIconButton: {
+ flex: 1,
+ height: 28,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radii.button
+ },
+ toolbarIconDisabled: {
+ opacity: 0.6
+ },
+ searchToggle: {
+ padding: spacing.xs
+ },
+ searchBar: {
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: colors.borderSubtle,
+ backgroundColor: colors.bgPanel
+ },
+ centered: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ emptyText: {
+ color: colors.textSecondary,
+ fontSize: typography.bodySize
+ }
+})
diff --git a/mobile/src/host-screen/host-screen-secondary-styles.ts b/mobile/src/host-screen/host-screen-secondary-styles.ts
new file mode 100644
index 00000000000..af14aa2b97a
--- /dev/null
+++ b/mobile/src/host-screen/host-screen-secondary-styles.ts
@@ -0,0 +1,139 @@
+import { StyleSheet } from 'react-native'
+import { colors, spacing, typography } from '../theme/mobile-theme'
+
+export const hostScreenSecondaryStyles = StyleSheet.create({
+ errorText: {
+ color: colors.statusRed,
+ fontSize: typography.bodySize
+ },
+ list: {
+ paddingBottom: spacing.lg
+ },
+ sectionHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.md,
+ paddingBottom: spacing.xs
+ },
+ sectionIcon: {
+ marginRight: spacing.xs
+ },
+ sectionRepoIcon: {
+ marginRight: spacing.xs
+ },
+ sectionTitle: {
+ fontSize: 11,
+ fontWeight: '600',
+ color: colors.textMuted,
+ textTransform: 'uppercase',
+ letterSpacing: 0.5
+ },
+ sectionCount: {
+ fontSize: 11,
+ color: colors.textMuted,
+ marginLeft: spacing.xs
+ },
+ separator: {
+ height: 1,
+ backgroundColor: colors.borderSubtle,
+ marginLeft: spacing.lg + 24,
+ marginRight: spacing.lg
+ },
+ filterModalHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: spacing.xs,
+ marginBottom: spacing.md
+ },
+ filterModalTitle: {
+ fontSize: 15,
+ fontWeight: '600',
+ color: colors.textPrimary
+ },
+ clearFiltersText: {
+ fontSize: 13,
+ color: colors.textSecondary
+ },
+ filterSectionLabel: {
+ fontSize: 11,
+ fontWeight: '600',
+ color: colors.textMuted,
+ textTransform: 'uppercase',
+ letterSpacing: 0.5,
+ marginBottom: spacing.xs,
+ paddingHorizontal: spacing.xs
+ },
+ filterGroup: {
+ backgroundColor: colors.bgPanel,
+ borderRadius: 12,
+ overflow: 'hidden',
+ marginBottom: spacing.md
+ },
+ filterRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: spacing.md,
+ paddingHorizontal: spacing.md + 2,
+ gap: spacing.sm
+ },
+ filterRowText: {
+ flex: 1,
+ fontSize: typography.bodySize,
+ color: colors.textPrimary
+ },
+ filterSeparator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: colors.borderSubtle,
+ marginHorizontal: spacing.md
+ },
+ filterRepoDot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4
+ },
+ confirmContent: {
+ paddingBottom: spacing.lg
+ },
+ confirmTitle: {
+ fontSize: 16,
+ fontWeight: '700',
+ color: colors.textPrimary
+ },
+ confirmMessage: {
+ fontSize: typography.bodySize,
+ color: colors.textSecondary,
+ marginTop: spacing.xs,
+ lineHeight: 20
+ },
+ confirmButtons: {
+ flexDirection: 'row',
+ gap: spacing.sm
+ },
+ confirmBtn: {
+ flex: 1,
+ paddingVertical: spacing.sm + 2,
+ borderRadius: 10,
+ alignItems: 'center'
+ },
+ confirmBtnCancel: {
+ backgroundColor: colors.bgPanel
+ },
+ confirmBtnDestructive: {
+ backgroundColor: colors.statusRed
+ },
+ confirmBtnPressed: {
+ opacity: 0.7
+ },
+ confirmBtnCancelText: {
+ fontSize: typography.bodySize,
+ fontWeight: '600',
+ color: colors.textSecondary
+ },
+ confirmBtnDestructiveText: {
+ fontSize: typography.bodySize,
+ fontWeight: '600',
+ color: '#fff'
+ }
+})
diff --git a/mobile/src/host-screen/host-screen-styles.ts b/mobile/src/host-screen/host-screen-styles.ts
new file mode 100644
index 00000000000..f4fdb7bed5d
--- /dev/null
+++ b/mobile/src/host-screen/host-screen-styles.ts
@@ -0,0 +1,7 @@
+import { hostScreenPrimaryStyles } from './host-screen-primary-styles'
+import { hostScreenSecondaryStyles } from './host-screen-secondary-styles'
+
+export const hostScreenStyles = {
+ ...hostScreenPrimaryStyles,
+ ...hostScreenSecondaryStyles
+}