From d2ce1c5ff1b76c7ce5cc680d516ccc6ec07ec281 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 29 Apr 2026 00:12:13 -0400 Subject: [PATCH] fix mobile terminal replay row mismatch Co-authored-by: Orca --- .../app/h/[hostId]/session/[worktreeId].tsx | 26 +++++++++++++++++-- mobile/src/terminal/TerminalWebView.tsx | 11 ++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index f36274c54ad..be01823fe65 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -69,11 +69,13 @@ const STATUS_LABELS: Record = { function TerminalPaneView({ handle, active, - onRef + onRef, + onWebReady }: { handle: string active: boolean onRef: (handle: string, ref: TerminalWebViewHandle | null) => void + onWebReady: (handle: string) => void }) { const setRef = useCallback( (ref: TerminalWebViewHandle | null) => { @@ -87,7 +89,11 @@ function TerminalPaneView({ pointerEvents={active ? 'auto' : 'none'} style={[styles.terminalPane, !active && styles.terminalPaneHidden]} > - + onWebReady(handle)} + /> ) } @@ -541,6 +547,21 @@ export default function SessionScreen() { [subscribeToTerminal] ) + const handleTerminalWebReady = useCallback( + (handle: string) => { + if (!initializedHandlesRef.current.has(handle)) { + return + } + // Why: the native WebView can reload independently of the session + // screen during Metro reloads or Android process churn. The old xterm + // buffer is gone, so force a fresh scrollback snapshot for this handle. + unsubscribeTerminal(handle) + initializedHandlesRef.current.delete(handle) + subscribeToTerminal(handle) + }, + [subscribeToTerminal, unsubscribeTerminal] + ) + async function handleSend() { if (!client || !activeHandle || !input.trim()) return @@ -771,6 +792,7 @@ export default function SessionScreen() { handle={terminal.handle} active={terminal.handle === activeHandle} onRef={setTerminalWebViewRef} + onWebReady={handleTerminalWebReady} /> ))} diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 11806e000ff..5d8b84466b5 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -14,6 +14,7 @@ export type TerminalWebViewHandle = { type Props = { style?: StyleProp + onWebReady?: () => void } type TerminalMessage = @@ -138,6 +139,11 @@ const XTERM_HTML = ` // managed by xterm, not a separate HTML gap. Never shrink below the // original init row count to avoid clipping active terminal content. function adjustRowsForViewport() { + // Why: mobile replays a live PTY snapshot and then applies live cursor- + // relative chunks from that same PTY. Resizing only the WebView xterm + // changes cursor coordinates and makes TUI repaint chunks duplicate or + // overlap existing frames. Keep xterm rows identical to the PTY. + return; if (!term || !term.element) return; // Why: active alternate-screen TUIs (Claude Code, vim, etc.) are exact // screen snapshots. Locally resizing the mobile xterm after replay can @@ -495,7 +501,7 @@ const XTERM_HTML = ` ` export const TerminalWebView = forwardRef(function TerminalWebView( - { style }, + { style, onWebReady }, ref ) { const webViewRef = useRef(null) @@ -541,6 +547,7 @@ export const TerminalWebView = forwardRef(function if (msg.type === 'web-ready') { isWebReadyRef.current = true + onWebReady?.() flushPendingMessages() } else if (msg.type === 'measure-result') { const resolve = measureResolveRef.current @@ -552,7 +559,7 @@ export const TerminalWebView = forwardRef(function } } }, - [flushPendingMessages] + [flushPendingMessages, onWebReady] ) const handleLoadStart = useCallback(() => {