Persist terminal fit state across session remounts

Add module-level Map to persist which terminals have been phone-fitted,
so navigating away and back auto-refits them instead of reverting to
desktop dimensions. Also adds fitPendingRef to prevent duplicate fits.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-04-28 19:48:01 -04:00
co-authored by Orca
parent 88d499868f
commit 20880745f5
2 changed files with 89 additions and 13 deletions
+51
View File
@@ -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 `<WebView>` 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.
+38 -13
View File
@@ -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<string, Set<string>>()
const STATUS_LABELS: Record<ConnectionState, string> = {
connecting: 'Connecting',
connected: 'Connected',
@@ -80,11 +85,29 @@ export default function SessionScreen() {
const [createError, setCreateError] = useState('')
const [actionTarget, setActionTarget] = useState<Terminal | null>(null)
const [renameTarget, setRenameTarget] = useState<Terminal | null>(null)
const [fittedHandles, setFittedHandles] = useState<Set<string>>(new Set())
const [fittedHandles, setFittedHandles] = useState<Set<string>>(
() => persistedFittedHandles.get(worktreeId!) ?? new Set()
)
const [fitPending, setFitPending] = useState(false)
const deviceTokenRef = useRef<string | null>(null)
const fittedHandlesRef = useRef<Set<string>>(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<string>) => Set<string>) => {
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<Set<string>>(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) {