diff --git a/docs/design/mobile-terminal-fit-mode.md b/docs/design/mobile-terminal-fit-mode.md index 7b1cb0a7f79..e7db441d616 100644 --- a/docs/design/mobile-terminal-fit-mode.md +++ b/docs/design/mobile-terminal-fit-mode.md @@ -409,6 +409,57 @@ Tradeoff: likely flaky. The next desktop fit pass can resize the PTY back to des - What exact mobile font size should define the fit grid? - ~~Should the desktop indicator live in terminal tab chrome, pane chrome, or a subtle in-terminal overlay?~~ Resolved: dismissible banner in the terminal pane (Section 4). +## Implementation Notes — Mobile WebView Touch & Display (April 2026) + +### CSS Scale Fit +- `computeFitScale()` measures xterm's rendered width vs viewport width +- Scale snaps to 1.0 when >= 0.95 (avoids sub-pixel gaps in fit-to-phone mode) +- Transform origin is top-left for predictable positioning + +### Row Fill +- After scaling, xterm may have fewer rows than needed to fill the phone viewport vertically +- `adjustRowsForViewport()` resizes xterm's row count to fill the available height +- This is display-only — the PTY is not resized +- Never shrinks below `initRows` to avoid clipping active terminal content + +### Custom Touch Handling +All touch gestures are intercepted in the capture phase (before xterm sees them): + +- **Single-finger at 1x zoom → xterm scrollback**: Scroll speed is compensated for CSS scale (`pixelDelta / (cellHeight * scale)`) so it matches the visual line height. Includes momentum/inertia scrolling. +- **Single-finger when zoomed in → pan**: Moves the viewport around the zoomed content. Pan is clamped to content bounds. +- **Two-finger → pinch-to-zoom**: Zooms the terminal surface CSS transform directly (1x–5x range). Zoom is centered on the pinch midpoint. Snaps back to 1x when released below 1.05x. +- **Tap suppression**: `mousedown` and `click` are suppressed to prevent cursor jumping in the TUI. + +### Why Custom Touch Over Native WebView Gestures +- Native WebView scroll competed with xterm's internal scrollback (two scroll layers) +- Native WebView zoom operates on HTML page boundaries — can't zoom on content near the bottom edge +- CSS scale makes touch coordinates misaligned with visual positions — custom handlers compensate + +### Keyboard-Aware Terminal Resizing — NOT WORKING (Attempts Documented) +When the soft keyboard appears, the terminal content should push up so the latest output stays visible above the keyboard. We tried several approaches, none worked: + +1. **`window.resize` event in WebView**: The HTML `resize` event doesn't fire reliably when KeyboardAvoidingView (behavior: 'padding') reshapes the layout. The WebView's `window.innerHeight` may not update. + +2. **`onLayout` on the WebView**: Added `onLayout` directly on the `` component to detect frame changes. Did not fire when the keyboard appeared. + +3. **`onLayout` on the parent `terminalFrame` View**: The `terminalFrame` has `flex: 1` and should shrink when KeyboardAvoidingView adds bottom padding. Added `onLayout` to measure the new height and send it to the WebView JS via `postMessage`. Did not fire with a different height value. + +4. **React Native `Keyboard` API**: Used `Keyboard.addListener('keyboardWillShow')` to get the keyboard height directly. Computed `reducedHeight = fullHeight - keyboardHeight` and sent it to the WebView via `postMessage`. The message was sent but the terminal did not resize. + +### Possible Root Causes (Uninvestigated) +- The `postMessage` from React Native to the WebView may have timing issues or may not be received during keyboard animation +- `adjustRowsForViewport()` may need to handle the case where `neededRows < initRows` (keyboard takes enough space that fewer rows are needed than the PTY's original count) +- KeyboardAvoidingView with `behavior: 'padding'` may not actually cause flex children to re-layout — could try `behavior: 'height'` +- The WebView's internal viewport may be decoupled from its native frame size +- May need to use `injectJavaScript` instead of `postMessage` for more direct execution +- Could expose functions on `window` object and call them via `injectJavaScript` to bypass the message queue entirely + +### Future Approach Ideas +- Try `behavior: 'height'` on KeyboardAvoidingView +- Use `injectJavaScript` with a `window`-exposed function for immediate execution +- Add debug logging to verify each step of the chain (keyboard event → message sent → message received → rows calculated → resize called) +- Consider `react-native-keyboard-controller` for more granular control + ## Recommendation Implement explicit mobile-fit mode with runtime-owned PTY resize and desktop auto-fit suppression. diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index d515bd32639..d3dffd55ee4 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -51,6 +51,11 @@ const ACCESSORY_KEYS: AccessoryKey[] = [ { label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' } ] +// Why: persists fitted-handle sets across component remounts (e.g. navigating +// away and back). React state resets on unmount, but we need to remember which +// terminals were phone-fitted so we can auto-refit them on return. +const persistedFittedHandles = new Map>() + const STATUS_LABELS: Record = { connecting: 'Connecting', connected: 'Connected', @@ -80,11 +85,29 @@ export default function SessionScreen() { const [createError, setCreateError] = useState('') const [actionTarget, setActionTarget] = useState(null) const [renameTarget, setRenameTarget] = useState(null) - const [fittedHandles, setFittedHandles] = useState>(new Set()) + const [fittedHandles, setFittedHandles] = useState>( + () => persistedFittedHandles.get(worktreeId!) ?? new Set() + ) const [fitPending, setFitPending] = useState(false) const deviceTokenRef = useRef(null) const fittedHandlesRef = useRef>(fittedHandles) fittedHandlesRef.current = fittedHandles + // Why: wraps setFittedHandles to also persist to the module-level map, + // so fitted state survives component remount when navigating away and back. + const updateFittedHandles = useCallback( + (updater: (prev: Set) => Set) => { + setFittedHandles((prev) => { + const next = updater(prev) + persistedFittedHandles.set(worktreeId!, next) + return next + }) + }, + [worktreeId] + ) + // Why: the subscribe callback captures fitPending via closure, but needs + // the current value to avoid re-fitting while a fit is already in progress. + const fitPendingRef = useRef(false) + fitPendingRef.current = fitPending // Why: tracks terminals the user explicitly restored to desktop size. // Without this, switching away and back would auto-fit them again. const manuallyRestoredRef = useRef>(new Set()) @@ -167,11 +190,11 @@ export default function SessionScreen() { }) if (!response.ok) return - setFittedHandles((prev) => new Set(prev).add(handle)) + updateFittedHandles((prev) => new Set(prev).add(handle)) resubscribeWithoutFocus(handle) setTimeout(() => termRef.current?.resetZoom(), 500) }, - [resubscribeWithoutFocus] + [resubscribeWithoutFocus, updateFittedHandles] ) const handleFitToPhone = useCallback(async () => { @@ -201,7 +224,7 @@ export default function SessionScreen() { if (!response.ok) return manuallyRestoredRef.current.add(handle) - setFittedHandles((prev) => { + updateFittedHandles((prev) => { const next = new Set(prev) next.delete(handle) return next @@ -211,7 +234,7 @@ export default function SessionScreen() { } catch { // Restore failed — keep current state. } - }, [client, activeHandle, resubscribeWithoutFocus]) + }, [client, activeHandle, resubscribeWithoutFocus, updateFittedHandles]) useEffect(() => { let rpcClient: RpcClient | null = null @@ -290,15 +313,17 @@ export default function SessionScreen() { } else { writeLines(data.lines as string[] | string | undefined) } - // Why: only auto-fit terminals created during this mobile session. - // Existing desktop terminals keep their dimensions until the user - // explicitly chooses "Fit to Phone" — auto-fitting every terminal - // on subscribe was confusing because it showed "Restore Desktop - // Size" for terminals the user never intentionally resized. + // Why: auto-fit terminals that (a) were created during this + // mobile session, or (b) the user previously fitted to phone + // and didn't manually restore. Case (b) handles the scenario + // where navigating away disconnects the WebSocket (triggering + // auto-restore on desktop) and coming back should re-apply + // the phone fit so the user doesn't lose their aspect ratio. if ( - mobileCreatedHandlesRef.current.has(handle) && - !fittedHandlesRef.current.has(handle) && - !manuallyRestoredRef.current.has(handle) + !manuallyRestoredRef.current.has(handle) && + (mobileCreatedHandlesRef.current.has(handle) || + fittedHandlesRef.current.has(handle)) && + !fitPendingRef.current ) { setTimeout(() => { if (activeHandleRef.current === handle && subscribeSeqRef.current === seq) {