fix mobile terminal streaming

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-04-30 18:03:23 -04:00
co-authored by Orca
parent 5af331c93c
commit 5f8c9ef312
9 changed files with 841 additions and 103 deletions
+105 -10
View File
@@ -2,37 +2,130 @@
React Native companion app for Orca. Monitor worktrees, view terminal output, and send commands from your phone.
Local development uses two processes:
- Orca desktop/Electron from the repo root. This hosts the mobile WebSocket RPC server on port `6768`.
- Expo Metro from `mobile/`. This serves the React Native app on port `8081`.
## Prerequisites
- Node.js 24+
- pnpm
- Expo Go app on your phone (from App Store / Google Play), **or** a dev client build
- Xcode and/or Android Studio tooling for simulator or device builds
- Expo Go on your phone, or a development client build when native modules are needed
- Phone and desktop on the same LAN when testing a physical phone
## Quick Start
## Start Desktop Orca
From the repository root:
```bash
pnpm install
pnpm dev
```
Confirm the mobile RPC server is listening:
```bash
lsof -nP -iTCP:6768 -sTCP:LISTEN
```
Restart `pnpm dev` after changing Electron main-process code. Metro hot reload only applies to the mobile JavaScript bundle.
## Start The Mobile App
```bash
cd mobile
pnpm install
pnpm start # starts Expo dev server
pnpm start
```
Scan the QR code shown in the terminal with your phone's camera (iOS) or the Expo Go app (Android).
Scan the Expo QR code with your phone's camera on iOS, or Expo Go on Android.
For a native dev-client build:
```bash
pnpm exec expo run:android
pnpm exec expo run:ios
pnpm start --dev-client
```
## Pair With Desktop Orca
1. Open Orca desktop.
2. Go to Settings > Mobile.
3. Scan the pairing QR code from the mobile app.
4. Confirm the mobile host endpoint is `ws://<desktop-ip>:6768`.
For the Android emulator, use `ws://10.0.2.2:6768`. For a physical phone, use the desktop LAN IP, for example `ws://192.168.0.179:6768`.
If the phone has a stale host entry, remove it from the app and pair again.
## Development Paths
### I have an Android phone
### Android Phone
1. Install Expo Go from Google Play
2. Run `pnpm start`, scan QR with Expo Go
3. For native modules (camera): `npx expo prebuild --platform android && cd android && ./gradlew assembleDebug`
4. Install APK: `adb install android/app/build/outputs/apk/debug/app-debug.apk`
5. Run with `pnpm start --dev-client`
3. For native modules: `pnpm exec expo run:android`
4. Run with `pnpm start --dev-client`
### I only have a Mac (iOS Simulator)
### iOS Simulator
1. Install Xcode from the App Store
2. Run `pnpm start --ios` to open in iOS Simulator
## Physical Phone Debugging
The phone can be inspected through the connected device tooling:
```bash
orca snapshot --json
orca click --element @e3 --json
orca fill --element @e1 --value "ls" --json
orca screenshot --json
```
Use `snapshot` first to find the current element refs, then click/fill those refs. After mobile file edits, Metro usually hot reloads automatically, but navigating out of and back into the session screen can be useful because it re-runs `terminal.subscribe`.
## Terminal Streaming Repro Without A Phone
Use this when terminal output does not render on device and you need to split server streaming bugs from WebView/UI bugs:
```bash
cd mobile
ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 pnpm exec tsx scripts/test-subscribe.ts <deviceToken>
```
You can pass a worktree selector as the second argument:
```bash
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> "id:<worktreeId>"
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> "path:/absolute/worktree/path"
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> "name:my-worktree"
```
The expected result includes:
```text
streamSawMarker: true
readSawMarker: true
```
If this repro fails, debug the desktop runtime/PTY path before the mobile WebView. If it passes but the phone is blank, debug the session screen or `TerminalWebView` readiness/queueing path.
## Validation
Run these checks before committing mobile terminal changes:
```bash
cd mobile
pnpm exec tsc --noEmit
pnpm lint
cd ..
pnpm typecheck:node
```
## Mock Server
Develop the mobile app without a running Orca desktop instance:
@@ -58,8 +151,10 @@ mobile/
│ ├── index.tsx # Home screen — paired hosts list
│ └── pair-scan.tsx # QR code scanning screen
├── src/
│ └── transport/ # WebSocket RPC client (TBD)
│ ├── terminal/ # Terminal WebView and xterm bridge
│ └── transport/ # WebSocket RPC client
├── scripts/
│ ├── test-subscribe.ts # Desktop streaming repro without a phone
│ └── mock-server.ts # Standalone mock WebSocket server
└── assets/ # App icons and splash screen
```
+89 -59
View File
@@ -13,6 +13,10 @@ import { useLocalSearchParams } from 'expo-router'
import { connect, type RpcClient } from '../../../../src/transport/rpc-client'
import { loadHosts } from '../../../../src/transport/host-store'
import type { ConnectionState, RpcSuccess } from '../../../../src/transport/types'
import {
TerminalWebView,
type TerminalWebViewHandle
} from '../../../../src/terminal/TerminalWebView'
type Terminal = {
handle: string
@@ -25,11 +29,12 @@ export default function SessionScreen() {
const [client, setClient] = useState<RpcClient | null>(null)
const [connState, setConnState] = useState<ConnectionState>('disconnected')
const [terminals, setTerminals] = useState<Terminal[]>([])
const [output, setOutput] = useState('')
const [input, setInput] = useState('')
const [activeHandle, setActiveHandle] = useState<string | null>(null)
const scrollRef = useRef<ScrollView>(null)
const termRef = useRef<TerminalWebViewHandle>(null)
const unsubRef = useRef<(() => void) | null>(null)
const activeHandleRef = useRef<string | null>(null)
const terminalSizeRef = useRef({ cols: 80, rows: 24 })
useEffect(() => {
let rpcClient: RpcClient | null = null
@@ -49,6 +54,62 @@ export default function SessionScreen() {
}
}, [hostId])
useEffect(() => {
unsubRef.current?.()
unsubRef.current = null
activeHandleRef.current = null
setActiveHandle(null)
setTerminals([])
termRef.current?.clear()
}, [worktreeId])
const writeLines = useCallback((lines: string[] | string | undefined) => {
const text = Array.isArray(lines) ? lines.join('\r\n') : (lines ?? '')
if (text) {
termRef.current?.write(text)
if (Array.isArray(lines)) {
termRef.current?.write('\r\n')
}
}
}, [])
const subscribeToTerminal = useCallback(
(handle: string) => {
unsubRef.current?.()
if (!client) return
termRef.current?.clear()
const unsub = client.subscribe('terminal.subscribe', { terminal: handle }, (result) => {
const data = result as Record<string, unknown>
if (data.type === 'scrollback') {
// Why: init xterm at the desktop's exact cols/rows so escape
// codes with absolute cursor positioning render correctly.
// CSS transform: scale() in TerminalWebView shrinks the canvas
// to fit the phone viewport, producing a 1:1 miniature.
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
terminalSizeRef.current = { cols, rows }
termRef.current?.init(cols, rows)
// Why: prefer serialized xterm buffer (ANSI escape string that
// reconstructs the exact screen state) over line-based tail
// because TUI apps use absolute cursor positioning that only
// works at the original terminal dimensions.
if (typeof data.serialized === 'string' && data.serialized.length > 0) {
termRef.current?.write(data.serialized)
} else {
writeLines(data.lines as string[] | string | undefined)
}
} else if (data.type === 'data') {
termRef.current?.write(data.chunk as string)
}
})
unsubRef.current = unsub
},
[client, writeLines]
)
const fetchTerminals = useCallback(async () => {
if (!client) return
@@ -60,16 +121,26 @@ export default function SessionScreen() {
const result = (response as RpcSuccess).result as { terminals: Terminal[] }
setTerminals(result.terminals)
const active = result.terminals.find((t) => t.isActive) ?? result.terminals[0]
if (active && active.handle !== activeHandle) {
setActiveHandle(active.handle)
subscribeToTerminal(active.handle)
const current = activeHandleRef.current
if (!current || !result.terminals.some((t) => t.handle === current)) {
const active = result.terminals.find((t) => t.isActive) ?? result.terminals[0]
if (active) {
activeHandleRef.current = active.handle
setActiveHandle(active.handle)
subscribeToTerminal(active.handle)
} else {
unsubRef.current?.()
unsubRef.current = null
activeHandleRef.current = null
setActiveHandle(null)
termRef.current?.clear()
}
}
}
} catch {
// Will retry on reconnect
// Failed to list terminals
}
}, [client, worktreeId, activeHandle])
}, [client, worktreeId, subscribeToTerminal])
useEffect(() => {
if (connState === 'connected') {
@@ -77,32 +148,14 @@ export default function SessionScreen() {
}
}, [connState, fetchTerminals])
function subscribeToTerminal(handle: string) {
unsubRef.current?.()
if (!client) return
setOutput('')
const unsub = client.subscribe('terminal.subscribe', { terminal: handle }, (result) => {
const data = result as Record<string, unknown>
if (data.type === 'scrollback') {
setOutput(data.lines as string)
scrollToBottom()
} else if (data.type === 'data') {
setOutput((prev) => prev + (data.chunk as string))
scrollToBottom()
}
})
unsubRef.current = unsub
}
function scrollToBottom() {
setTimeout(() => {
scrollRef.current?.scrollToEnd({ animated: true })
}, 50)
}
const switchTab = useCallback(
(handle: string) => {
activeHandleRef.current = handle
setActiveHandle(handle)
subscribeToTerminal(handle)
},
[subscribeToTerminal]
)
async function handleSend() {
if (!client || !activeHandle || !input.trim()) return
@@ -142,10 +195,7 @@ export default function SessionScreen() {
<Pressable
key={t.handle}
style={[styles.tab, t.handle === activeHandle && styles.tabActive]}
onPress={() => {
setActiveHandle(t.handle)
subscribeToTerminal(t.handle)
}}
onPress={() => switchTab(t.handle)}
>
<Text
style={[styles.tabText, t.handle === activeHandle && styles.tabTextActive]}
@@ -158,15 +208,7 @@ export default function SessionScreen() {
</ScrollView>
)}
<ScrollView
ref={scrollRef}
style={styles.outputScroll}
contentContainerStyle={styles.outputContent}
>
<Text style={styles.outputText} selectable>
{output || 'Waiting for output...'}
</Text>
</ScrollView>
<TerminalWebView ref={termRef} />
<View style={styles.inputBar}>
<TextInput
@@ -238,18 +280,6 @@ const styles = StyleSheet.create({
tabTextActive: {
color: '#e0e0e0'
},
outputScroll: {
flex: 1
},
outputContent: {
padding: 12
},
outputText: {
color: '#d4d4d4',
fontFamily: 'monospace',
fontSize: 13,
lineHeight: 18
},
inputBar: {
flexDirection: 'row',
alignItems: 'center',
+1
View File
@@ -26,6 +26,7 @@
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "^4.23.0",
"react-native-web": "^0.21.2",
"react-native-webview": "^13.16.1",
"react-native-worklets": "^0.7.4",
"ws": "^8.18.0",
"zod": "^4.3.6",
+38 -21
View File
@@ -13,7 +13,7 @@ importers:
version: 2.2.0(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))
expo:
specifier: ^55.0.17
version: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
version: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-camera:
specifier: ^55.0.16
version: 55.0.16(@types/emscripten@1.41.5)(expo@55.0.17)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
@@ -50,6 +50,9 @@ importers:
react-native-web:
specifier: ^0.21.2
version: 0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0)
react-native-webview:
specifier: ^13.16.1
version: 13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
react-native-worklets:
specifier: ^0.7.4
version: 0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
@@ -4665,6 +4668,12 @@ packages:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
react-native-webview@13.16.1:
resolution: {integrity: sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==}
peerDependencies:
react: '*'
react-native: '*'
react-native-worklets@0.7.4:
resolution: {integrity: sha512-NYOdM1MwBb3n+AtMqy1tFy3Mn8DliQtd8sbzAVRf9Gc+uvQ0zRfxN7dS8ZzoyX7t6cyQL5THuGhlnX+iFlQTag==}
peerDependencies:
@@ -6652,7 +6661,7 @@ snapshots:
connect: 3.7.0
debug: 4.4.3
dnssd-advertise: 1.1.4
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-server: 55.0.8
fetch-nodeshim: 0.4.10
getenv: 2.0.0
@@ -6750,7 +6759,7 @@ snapshots:
'@expo/dom-webview@55.0.5(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)':
dependencies:
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react: 19.2.0
react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)
@@ -6808,7 +6817,7 @@ snapshots:
dependencies:
'@expo/dom-webview': 55.0.5(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
anser: 1.4.10
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react: 19.2.0
react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)
stacktrace-parser: 0.1.11
@@ -6835,7 +6844,7 @@ snapshots:
postcss: 8.4.49
resolve-from: 5.0.0
optionalDependencies:
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -6846,7 +6855,7 @@ snapshots:
dependencies:
'@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
anser: 1.4.10
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
pretty-format: 29.7.0
react: 19.2.0
react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)
@@ -6910,7 +6919,7 @@ snapshots:
'@expo/json-file': 10.0.13
'@react-native/normalize-colors': 0.83.6
debug: 4.4.3
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
resolve-from: 5.0.0
semver: 7.7.4
xml2js: 0.6.0
@@ -6931,7 +6940,7 @@ snapshots:
'@expo/router-server@55.0.15(@expo/metro-runtime@55.0.10)(expo-constants@55.0.15)(expo-font@55.0.6)(expo-router@55.0.13)(expo-server@55.0.8)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react@19.2.0)':
dependencies:
debug: 4.4.3
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-constants: 55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))
expo-font: 55.0.6(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
expo-server: 55.0.8
@@ -8364,7 +8373,7 @@ snapshots:
resolve-from: 5.0.0
optionalDependencies:
'@babel/runtime': 7.29.2
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
transitivePeerDependencies:
- '@babel/core'
- supports-color
@@ -9190,7 +9199,7 @@ snapshots:
expo-asset@55.0.16(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3):
dependencies:
'@expo/image-utils': 0.8.13(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-constants: 55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))
react: 19.2.0
react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)
@@ -9201,7 +9210,7 @@ snapshots:
expo-camera@55.0.16(@types/emscripten@1.41.5)(expo@55.0.17)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
dependencies:
barcode-detector: 3.1.2(@types/emscripten@1.41.5)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react: 19.2.0
react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)
optionalDependencies:
@@ -9212,32 +9221,32 @@ snapshots:
expo-constants@55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)):
dependencies:
'@expo/env': 2.1.1
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)
transitivePeerDependencies:
- supports-color
expo-file-system@55.0.17(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)):
dependencies:
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)
expo-font@55.0.6(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
dependencies:
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
fontfaceobserver: 2.3.0
react: 19.2.0
react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)
expo-glass-effect@55.0.10(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
dependencies:
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react: 19.2.0
react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)
expo-image@55.0.9(expo@55.0.17)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
dependencies:
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react: 19.2.0
react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)
sf-symbols-typescript: 2.2.0
@@ -9246,7 +9255,7 @@ snapshots:
expo-keep-awake@55.0.6(expo@55.0.17)(react@19.2.0):
dependencies:
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react: 19.2.0
expo-linking@55.0.14(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
@@ -9336,7 +9345,7 @@ snapshots:
client-only: 0.0.1
debug: 4.4.3
escape-string-regexp: 4.0.0
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-constants: 55.0.15(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))
expo-glass-effect: 55.0.10(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
expo-image: 55.0.9(expo@55.0.17)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
@@ -9383,13 +9392,13 @@ snapshots:
expo-symbols@55.0.7(expo-font@55.0.6)(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
dependencies:
'@expo-google-fonts/material-symbols': 0.4.34
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-font: 55.0.6(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
react: 19.2.0
react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)
sf-symbols-typescript: 2.2.0
expo@55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3):
expo@55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.29.2
'@expo/cli': 55.0.26(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.15)(expo-font@55.0.6)(expo-router@55.0.13)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
@@ -9419,6 +9428,7 @@ snapshots:
optionalDependencies:
'@expo/dom-webview': 55.0.5(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.17)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
react-native-webview: 13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
transitivePeerDependencies:
- '@babel/core'
- bufferutil
@@ -10099,7 +10109,7 @@ snapshots:
'@jest/create-cache-key-function': 29.7.0
'@jest/globals': 29.7.0
babel-jest: 29.7.0(@babel/core@7.29.0)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
jest-environment-jsdom: 29.7.0
jest-snapshot: 29.7.0
jest-watch-select-projects: 2.0.0
@@ -11370,6 +11380,13 @@ snapshots:
transitivePeerDependencies:
- encoding
react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
dependencies:
escape-string-regexp: 4.0.0
invariant: 2.2.4
react: 19.2.0
react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)
react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
dependencies:
'@babel/core': 7.29.0
+241
View File
@@ -0,0 +1,241 @@
/**
* Lightweight terminal streaming repro for the mobile WebSocket RPC.
*
* Usage:
* pnpm exec tsx scripts/test-subscribe.ts <deviceToken> [worktreeSelector]
*
* Example:
* pnpm exec tsx scripts/test-subscribe.ts "$TOKEN" \
* "id:repo-id::/path/to/worktree"
*/
import WebSocket from 'ws'
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
const token = process.argv[2]
const worktreeSelector = process.argv[3]
const marker = `MOBILE_STREAM_${Date.now()}`
type RpcResponse = {
id: string
ok: boolean
streaming?: true
result?: Record<string, unknown>
error?: { code: string; message: string }
_meta?: { runtimeId: string }
}
type PendingRequest = {
method: string
resolve: (response: RpcResponse) => void
reject: (error: Error) => void
}
if (!token) {
console.error('Usage: pnpm exec tsx scripts/test-subscribe.ts <deviceToken> [worktreeSelector]')
process.exit(1)
}
let reqId = 0
const pending = new Map<string, PendingRequest>()
let streamSawMarker = false
let readSawMarker = false
let activeHandle: string | null = null
let runtimeId = ''
function nextId(): string {
reqId += 1
return `test-${reqId}`
}
function send(ws: WebSocket, method: string, params?: unknown): Promise<RpcResponse> {
const id = nextId()
ws.send(JSON.stringify({ id, deviceToken: token, method, params }))
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pending.delete(id)
reject(new Error(`Timed out waiting for ${method}`))
}, 10_000)
pending.set(id, {
method,
resolve: (response) => {
clearTimeout(timeout)
resolve(response)
},
reject: (error) => {
clearTimeout(timeout)
reject(error)
}
})
})
}
function formatResponse(response: RpcResponse): string {
return JSON.stringify(response.result ?? response.error ?? response).slice(0, 1000)
}
async function chooseWorktree(ws: WebSocket): Promise<string> {
if (worktreeSelector) return worktreeSelector
const response = await send(ws, 'worktree.ps')
if (!response.ok) {
throw new Error(`worktree.ps failed: ${formatResponse(response)}`)
}
const worktrees = (response.result?.worktrees ?? []) as Array<{
worktreeId: string
liveTerminalCount: number
branch: string
path: string
}>
const selected = worktrees.find((w) => w.liveTerminalCount > 0) ?? worktrees[0]
if (!selected) {
throw new Error('No worktrees returned by worktree.ps')
}
console.log(`worktree: ${selected.branch || '(no branch)'} ${selected.path}`)
return `id:${selected.worktreeId}`
}
async function chooseTerminal(ws: WebSocket, worktree: string): Promise<string> {
const list = await send(ws, 'terminal.list', { worktree })
if (!list.ok) {
throw new Error(`terminal.list failed: ${formatResponse(list)}`)
}
const terminals = (list.result?.terminals ?? []) as Array<{
handle: string
title: string
preview: string
lastOutputAt: number | null
}>
if (terminals.length > 0) {
const selected = terminals[0]!
console.log(`terminal: ${selected.title || selected.handle} ${selected.handle}`)
return selected.handle
}
const created = await send(ws, 'terminal.create', {
worktree,
title: 'mobile-stream-repro'
})
if (!created.ok) {
throw new Error(`terminal.create failed: ${formatResponse(created)}`)
}
const terminal = created.result?.terminal as { handle?: string; title?: string } | undefined
if (!terminal?.handle) {
throw new Error(`terminal.create returned no handle: ${formatResponse(created)}`)
}
console.log(`terminal: ${terminal.title || terminal.handle} ${terminal.handle}`)
return terminal.handle
}
async function run(ws: WebSocket): Promise<void> {
console.log(`connected: ${WS_URL}`)
const worktree = await chooseWorktree(ws)
const handle = await chooseTerminal(ws, worktree)
activeHandle = handle
void send(ws, 'terminal.subscribe', { terminal: handle }).catch((error) => {
console.error(`subscribe failed: ${error.message}`)
})
await new Promise((resolve) => setTimeout(resolve, 500))
const sendResponse = await send(ws, 'terminal.send', {
terminal: handle,
text: `echo ${marker}`,
enter: true
})
if (!sendResponse.ok) {
throw new Error(`terminal.send failed: ${formatResponse(sendResponse)}`)
}
console.log(`sent marker: ${marker}`)
const deadline = Date.now() + 5_000
while (Date.now() < deadline && !streamSawMarker && !readSawMarker) {
await new Promise((resolve) => setTimeout(resolve, 500))
const read = await send(ws, 'terminal.read', { terminal: handle })
if (!read.ok) {
throw new Error(`terminal.read failed: ${formatResponse(read)}`)
}
const terminal = read.result?.terminal as { tail?: string[]; preview?: string } | undefined
const text = [...(terminal?.tail ?? []), terminal?.preview ?? ''].join('\n')
if (text.includes(marker)) {
readSawMarker = true
}
}
console.log(`runtime: ${runtimeId || '(unknown)'}`)
console.log(`streamSawMarker: ${streamSawMarker}`)
console.log(`readSawMarker: ${readSawMarker}`)
if (!streamSawMarker && !readSawMarker) {
throw new Error('terminal output did not reach stream or terminal.read')
}
}
const ws = new WebSocket(WS_URL)
ws.on('open', () => {
run(ws)
.then(() => {
ws.close()
process.exit(0)
})
.catch((error) => {
console.error(error.message)
ws.close()
process.exit(1)
})
})
ws.on('message', (data) => {
const response = JSON.parse(data.toString()) as RpcResponse
if (response._meta?.runtimeId) {
runtimeId = response._meta.runtimeId
}
const result = response.result
if (response.streaming && result?.type === 'data') {
const chunk = typeof result.chunk === 'string' ? result.chunk : ''
if (chunk.includes(marker)) {
streamSawMarker = true
}
}
if (response.streaming && result?.type === 'scrollback') {
const lines = Array.isArray(result.lines) ? result.lines.join('\n') : String(result.lines ?? '')
if (lines.includes(marker)) {
streamSawMarker = true
}
}
const pendingRequest = pending.get(response.id)
if (!pendingRequest) {
return
}
if (response.streaming && pendingRequest.method === 'terminal.subscribe') {
return
}
pending.delete(response.id)
pendingRequest.resolve(response)
})
ws.on('close', () => {
for (const request of pending.values()) {
request.reject(new Error('WebSocket closed'))
}
pending.clear()
})
ws.on('error', (error) => {
console.error(`WebSocket error: ${error.message}`)
if (activeHandle) {
console.error(`active terminal: ${activeHandle}`)
}
process.exit(1)
})
+272
View File
@@ -0,0 +1,272 @@
import { useRef, useCallback, forwardRef, useImperativeHandle } from 'react'
import { StyleSheet } from 'react-native'
import { WebView } from 'react-native-webview'
import type { WebViewMessageEvent } from 'react-native-webview'
export type TerminalWebViewHandle = {
write: (data: string) => void
init: (cols: number, rows: number) => void
clear: () => void
}
type Props = object
type TerminalMessage =
| { type: 'write'; data: string }
| { type: 'init'; cols: number; rows: number }
| { type: 'clear' }
// Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor
// positioning designed for the desktop's terminal dimensions (~150 cols).
// Rendering at phone-native dimensions (55 cols) garbles TUI output because
// row/col coordinates don't match. Instead, we initialize xterm at the
// desktop's exact cols/rows and use CSS transform: scale() to shrink the
// canvas to fit the phone viewport. This produces an accurate miniature of
// the desktop screen. Pinch-to-zoom is enabled for readability.
const XTERM_HTML = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=0.5, maximum-scale=5, user-scalable=yes">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
overflow: auto;
background: #1a1b26;
-webkit-overflow-scrolling: touch;
}
#terminal-container {
transform-origin: top left;
overflow: visible;
}
</style>
</head>
<body>
<div id="terminal-container"></div>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script>
(function() {
var FONT_SIZE = 13;
var container = document.getElementById('terminal-container');
var term = null;
var writeQueue = [];
var ready = false;
var currentScale = 1;
function applyScale() {
if (!term) return;
var screen = container.querySelector('.xterm-screen');
if (!screen) return;
var termWidth = screen.offsetWidth;
var viewWidth = window.innerWidth;
if (termWidth > viewWidth) {
currentScale = viewWidth / termWidth;
container.style.transform = 'scale(' + currentScale + ')';
container.style.width = (100 / currentScale) + '%';
} else {
currentScale = 1;
container.style.transform = 'none';
container.style.width = '100%';
}
}
function init(cols, rows) {
ready = false;
writeQueue = [];
if (term) term.dispose();
container.style.transform = 'none';
container.style.width = '100%';
term = new Terminal({
cols: cols || 80,
rows: rows || 24,
theme: {
background: '#1a1b26',
foreground: '#c0caf5',
cursor: '#c0caf5',
cursorAccent: '#1a1b26',
selectionBackground: '#33467c',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5'
},
fontFamily: '"Menlo", "Consolas", "DejaVu Sans Mono", monospace',
fontSize: FONT_SIZE,
scrollback: 5000,
disableStdin: true,
cursorBlink: false,
cursorStyle: 'bar',
cursorInactiveStyle: 'none',
convertEol: false,
allowProposedApi: true
});
term.open(container);
requestAnimationFrame(function() {
applyScale();
ready = true;
for (var i = 0; i < writeQueue.length; i++) {
term.write(writeQueue[i]);
}
writeQueue = [];
notify({ type: 'ready' });
});
}
function write(data) {
if (ready && term) {
term.write(data);
} else {
writeQueue.push(data);
}
}
function notify(msg) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify(msg));
}
}
function handleMsg(msg) {
if (msg.type === 'init') {
init(msg.cols, msg.rows);
} else if (msg.type === 'write') {
write(msg.data);
} else if (msg.type === 'clear') {
writeQueue = [];
if (term) { term.clear(); term.reset(); }
}
}
window.addEventListener('message', function(e) {
try {
handleMsg(typeof e.data === 'string' ? JSON.parse(e.data) : e.data);
} catch(ex) {}
});
document.addEventListener('message', function(e) {
try {
handleMsg(typeof e.data === 'string' ? JSON.parse(e.data) : e.data);
} catch(ex) {}
});
if (window.Terminal) {
notify({ type: 'web-ready' });
} else {
notify({ type: 'error', message: 'xterm failed to load' });
}
})();
</script>
</body>
</html>`
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(
function TerminalWebView(_props, ref) {
const webViewRef = useRef<WebView>(null)
const isWebReadyRef = useRef(false)
const pendingMessagesRef = useRef<TerminalMessage[]>([])
const sendToWebView = useCallback((msg: TerminalMessage) => {
webViewRef.current?.postMessage(JSON.stringify(msg))
}, [])
const flushPendingMessages = useCallback(() => {
const pending = pendingMessagesRef.current
pendingMessagesRef.current = []
for (const msg of pending) {
sendToWebView(msg)
}
}, [sendToWebView])
const postMessage = useCallback(
(msg: TerminalMessage) => {
// Why: React Native drops postMessage calls made before the WebView page
// has installed its message handlers, so terminal output must wait here.
if (!isWebReadyRef.current) {
pendingMessagesRef.current.push(msg)
return
}
sendToWebView(msg)
},
[sendToWebView]
)
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
let msg: Record<string, unknown>
try {
msg = JSON.parse(event.nativeEvent.data) as Record<string, unknown>
} catch {
return
}
if (msg.type === 'web-ready') {
isWebReadyRef.current = true
flushPendingMessages()
}
},
[flushPendingMessages]
)
const handleLoadStart = useCallback(() => {
isWebReadyRef.current = false
}, [])
useImperativeHandle(
ref,
() => ({
write(data: string) {
postMessage({ type: 'write', data })
},
init(cols: number, rows: number) {
postMessage({ type: 'init', cols, rows })
},
clear() {
postMessage({ type: 'clear' })
}
}),
[postMessage]
)
return (
<WebView
ref={webViewRef}
source={{ html: XTERM_HTML }}
style={styles.webview}
originWhitelist={['*']}
javaScriptEnabled
scrollEnabled
nestedScrollEnabled
showsVerticalScrollIndicator={false}
setBuiltInZoomControls={false}
scalesPageToFit={false}
androidLayerType="none"
onLoadStart={handleLoadStart}
onMessage={handleMessage}
/>
)
}
)
const styles = StyleSheet.create({
webview: {
flex: 1,
backgroundColor: '#1a1b26'
}
})
+34 -13
View File
@@ -7,6 +7,12 @@ type PendingRequest = {
type StreamingListener = (result: unknown) => void
type StreamRequest = {
method: string
params: unknown
listener: StreamingListener
}
export type RpcClient = {
sendRequest: (method: string, params?: unknown) => Promise<RpcResponse>
subscribe: (method: string, params: unknown, onData: StreamingListener) => () => void
@@ -31,7 +37,7 @@ export function connect(
let intentionallyClosed = false
const pending = new Map<string, PendingRequest>()
const streamListeners = new Map<string, StreamingListener>()
const streamListeners = new Map<string, StreamRequest>()
const stateListeners = new Set<(state: ConnectionState) => void>()
// Callers that are waiting for the socket to reach 'connected' state
const connectWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }> = []
@@ -75,6 +81,9 @@ export function connect(
ws.onopen = () => {
reconnectAttempt = 0
setState('connected')
for (const [id, stream] of streamListeners) {
sendRaw({ id, deviceToken, method: stream.method, params: stream.params })
}
}
ws.onmessage = (event) => {
@@ -88,9 +97,9 @@ export function connect(
const isStreaming = response.ok && (response as RpcSuccess).streaming === true
if (isStreaming) {
const listener = streamListeners.get(response.id)
if (listener && response.ok) {
listener((response as RpcSuccess).result)
const stream = streamListeners.get(response.id)
if (stream && response.ok) {
stream.listener((response as RpcSuccess).result)
}
return
}
@@ -100,18 +109,18 @@ export function connect(
if (response.ok) {
const result = (response as RpcSuccess).result as Record<string, unknown> | null
if (result && result.type === 'end') {
const listener = streamListeners.get(response.id)
if (listener) {
listener(result)
const stream = streamListeners.get(response.id)
if (stream) {
stream.listener(result)
streamListeners.delete(response.id)
return
}
}
// Scrollback (first message from terminal.subscribe) also goes to stream listener
if (result && result.type === 'scrollback') {
const listener = streamListeners.get(response.id)
if (listener) {
listener(result)
const stream = streamListeners.get(response.id)
if (stream) {
stream.listener(result)
return
}
}
@@ -192,16 +201,28 @@ export function connect(
subscribe(method: string, params: unknown, onData: StreamingListener): () => void {
const id = nextId()
streamListeners.set(id, onData)
sendRaw({ id, deviceToken, method, params })
streamListeners.set(id, { method, params, listener: onData })
if (state === 'connected') {
sendRaw({ id, deviceToken, method, params })
}
return () => {
const stream = streamListeners.get(id)
streamListeners.delete(id)
sendRaw({
id: nextId(),
deviceToken,
method: 'terminal.unsubscribe',
params: { subscriptionId: id }
// Why: the runtime keys terminal subscription cleanup by terminal
// handle, not by the RPC request id used to open the stream.
params:
stream?.method === 'terminal.subscribe' &&
stream.params &&
typeof stream.params === 'object' &&
typeof (stream.params as { terminal?: unknown }).terminal === 'string'
? { subscriptionId: (stream.params as { terminal: string }).terminal }
: { subscriptionId: id }
})
}
},
@@ -0,0 +1,56 @@
# Mobile Terminal Output Streaming Findings
Date: 2026-04-28
## Scope
The investigation was limited to `mobile/`. Server-side files under `src/main/` were read for context only and were not modified.
## Verified Findings
- The phone can connect to `ws://192.168.0.179:6768`, list worktrees, list terminals, and call `terminal.send`.
- The phone's saved host token is valid; a direct WebSocket probe from the desktop using the same token can call `worktree.ps`, `terminal.list`, `terminal.subscribe`, `terminal.send`, and `terminal.read`.
- `TerminalWebView` can render text when the React Native side writes to it after xterm initializes. A temporary marker written after `init()` appeared visibly in the WebView.
- Messages posted to the WebView before its page installs message handlers can be dropped. The mobile fix queues `init`, `write`, and `clear` until the WebView reports `web-ready`.
- The WebView also has an internal queue for writes that arrive after `web-ready` but before xterm finishes `init()`.
- The selected physical-phone test worktree was `refs/heads/tasks-improvements` at `/Users/jinwoohong/orca/workspaces/orca/pr-1172-review`.
- For that test worktree, `terminal.subscribe` produced an initial `scrollback` event with an empty `lines` array and no serialized buffer.
- Sending commands to that test terminal returned `ok:true`, but a direct WebSocket `terminal.read` for the same handle still returned an empty tail and no live `data` chunks were observed.
- Creating a fresh terminal with `terminal.create` in that same worktree also returned a writable handle, but `terminal.send` followed by delayed `terminal.read` still returned an empty tail.
- Adding a mobile-side `terminal.read` fallback did not make the current physical-phone test terminal display output, because the direct WebSocket `terminal.read` response for `mobile-output-test` remained `tail: []` and `lastOutputAt: null` after `echo hi`.
- Calling `terminal.show` before `terminal.send` on `mobile-output-test` returned a connected/writable terminal with a `ptyId`, but a delayed `terminal.read` still returned an empty tail.
- Root cause found after building a no-phone repro: daemon-backed PTYs were forwarding provider data to the desktop renderer, but not into `runtime.onPtyData`. The runtime tail buffer and `terminal.subscribe` listeners are fed by `runtime.onPtyData`, so the phone saw accepted sends with no output.
- Fix: `src/main/ipc/pty.ts` now forwards `provider.onData` into `runtime.onPtyData` for non-`LocalPtyProvider` providers. Local PTYs already use the LocalPtyProvider configure hook, so the guard avoids duplicate local output.
- After restarting Electron, `pnpm exec tsx mobile/scripts/test-subscribe.ts <deviceToken>` passed: both `streamSawMarker` and `readSawMarker` were `true`.
- After the backend fix, the physical phone rendered the repro marker in `TerminalWebView`.
- Because of the empty tail/no live stream on that specific desktop terminal, the physical `ls` test did not prove the WebView display path after real PTY output.
## Changes Made
- `mobile/src/terminal/TerminalWebView.tsx`
- Added a native-side queue so WebView messages are not sent until the HTML reports `web-ready`.
- Added a WebView-side queue so writes wait until xterm finishes `init()`.
- `mobile/src/transport/rpc-client.ts`
- Stored full stream request metadata so active streams can be replayed after reconnect.
- Avoided sending a subscription before the socket reaches `connected`.
- Fixed `terminal.unsubscribe` to use the terminal handle as `subscriptionId`, matching runtime cleanup behavior.
- `mobile/app/h/[hostId]/session/[worktreeId].tsx`
- Clears and resubscribes when the worktree changes.
- Tracks the active terminal handle in a ref so stale handles are replaced when `terminal.list` changes.
- Routes scrollback and live data into `TerminalWebView`.
## Useful Commands
```bash
agent-device devices --json
agent-device snapshot --platform android --serial R3CX105QXRH --json
agent-device fill @e33 "ls" --platform android --serial R3CX105QXRH --json
agent-device click @e34 --platform android --serial R3CX105QXRH --json
agent-device screenshot /tmp/mobile-terminal.png --platform android --serial R3CX105QXRH --json
orca terminal send --terminal term_926b8898-f843-461a-acd2-482f741327ad --text r --json
orca terminal read --terminal term_926b8898-f843-461a-acd2-482f741327ad --json
```
## Next Debug Boundary
Do not repeat WebView readiness tests unless the WebView changes again; the WebView can render post-init writes. For future regressions, run the no-phone repro first. If it fails, debug the WebSocket/runtime/PTY bridge before touching the mobile UI. If it passes but the phone is blank, debug `TerminalWebView` or session-screen routing.
+5
View File
@@ -403,6 +403,11 @@ export function registerPtyHandlers(
const isLocalProvider = localProvider instanceof LocalPtyProvider
localDataUnsub = localProvider.onData((payload) => {
if (!(localProvider instanceof LocalPtyProvider)) {
// Why: daemon-backed PTYs emit through provider.onData, not the
// LocalPtyProvider configure hook. Runtime tails power mobile read/stream.
runtime?.onPtyData(payload.id, payload.data, Date.now())
}
if (mainWindow.isDestroyed()) {
// Why: clear the pending flush timer so it doesn't fire after the window
// is gone. Without this, macOS app re-activation leaks orphaned timers