From 5f8c9ef312416e2bd5fedb4a008df49052aeb0fb Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 28 Apr 2026 03:09:09 -0400 Subject: [PATCH] fix mobile terminal streaming Co-authored-by: Orca --- mobile/README.md | 115 +++++++- .../app/h/[hostId]/session/[worktreeId].tsx | 148 ++++++---- mobile/package.json | 1 + mobile/pnpm-lock.yaml | 59 ++-- mobile/scripts/test-subscribe.ts | 241 ++++++++++++++++ mobile/src/terminal/TerminalWebView.tsx | 272 ++++++++++++++++++ mobile/src/transport/rpc-client.ts | 47 ++- mobile/terminal-output-streaming-findings.md | 56 ++++ src/main/ipc/pty.ts | 5 + 9 files changed, 841 insertions(+), 103 deletions(-) create mode 100644 mobile/scripts/test-subscribe.ts create mode 100644 mobile/src/terminal/TerminalWebView.tsx create mode 100644 mobile/terminal-output-streaming-findings.md diff --git a/mobile/README.md b/mobile/README.md index 95ed3f30b1b..48d40d685cb 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -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://: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 +``` + +You can pass a worktree selector as the second argument: + +```bash +pnpm exec tsx scripts/test-subscribe.ts "id:" +pnpm exec tsx scripts/test-subscribe.ts "path:/absolute/worktree/path" +pnpm exec tsx scripts/test-subscribe.ts "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 ``` diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index b70142e344f..daf4c7bcfc0 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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(null) const [connState, setConnState] = useState('disconnected') const [terminals, setTerminals] = useState([]) - const [output, setOutput] = useState('') const [input, setInput] = useState('') const [activeHandle, setActiveHandle] = useState(null) - const scrollRef = useRef(null) + const termRef = useRef(null) const unsubRef = useRef<(() => void) | null>(null) + const activeHandleRef = useRef(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 + 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 - 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() { { - setActiveHandle(t.handle) - subscribeToTerminal(t.handle) - }} + onPress={() => switchTab(t.handle)} > )} - - - {output || 'Waiting for output...'} - - + [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 + 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 [worktreeSelector]') + process.exit(1) +} + +let reqId = 0 +const pending = new Map() +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 { + 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 { + 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 { + 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 { + 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) +}) diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx new file mode 100644 index 00000000000..1557165c7e5 --- /dev/null +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -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 = ` + + + + + + + + +
+ + + +` + +export const TerminalWebView = forwardRef( + function TerminalWebView(_props, ref) { + const webViewRef = useRef(null) + const isWebReadyRef = useRef(false) + const pendingMessagesRef = useRef([]) + + 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 + try { + msg = JSON.parse(event.nativeEvent.data) as Record + } 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 ( + + ) + } +) + +const styles = StyleSheet.create({ + webview: { + flex: 1, + backgroundColor: '#1a1b26' + } +}) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index cd3817ed56e..90601858886 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -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 subscribe: (method: string, params: unknown, onData: StreamingListener) => () => void @@ -31,7 +37,7 @@ export function connect( let intentionallyClosed = false const pending = new Map() - const streamListeners = new Map() + const streamListeners = new Map() 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 | 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 } }) } }, diff --git a/mobile/terminal-output-streaming-findings.md b/mobile/terminal-output-streaming-findings.md new file mode 100644 index 00000000000..5f9aac2b23a --- /dev/null +++ b/mobile/terminal-output-streaming-findings.md @@ -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 ` 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. diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 3a473661d17..efbc2a6323d 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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