fix: prevent mobile page scroll when typing with keyboard open

iOS Safari scrolls the document to bring xterm's hidden textarea into
view when the user types, pushing the entire UI off-screen. Fix with:
- CSS position:fixed on .app when keyboard is visible
- window.scroll listener to reset scroll position as safety net
- scroll reset in onKeyboardShow before and after fit/resize

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-03-19 11:47:18 +01:00
co-authored by Claude Opus 4.6
parent 0c4aac8029
commit 2491471ba5
2 changed files with 66 additions and 21 deletions
+54 -21
View File
@@ -36,15 +36,19 @@
const MobileDetection = { const MobileDetection = {
/** Check if device supports touch input */ /** Check if device supports touch input */
isTouchDevice() { isTouchDevice() {
return 'ontouchstart' in window || return (
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 || navigator.maxTouchPoints > 0 ||
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches); (window.matchMedia && window.matchMedia('(pointer: coarse)').matches)
);
}, },
/** Check if device is iOS (iPhone, iPad, iPod) */ /** Check if device is iOS (iPhone, iPad, iPod) */
isIOS() { isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) || return (
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
);
}, },
/** Check if browser is Safari */ /** Check if browser is Safari */
@@ -77,7 +81,14 @@ const MobileDetection = {
const isTouch = this.isTouchDevice(); const isTouch = this.isTouchDevice();
// Remove existing device classes // Remove existing device classes
body.classList.remove('device-mobile', 'device-tablet', 'device-desktop', 'touch-device', 'ios-device', 'safari-browser'); body.classList.remove(
'device-mobile',
'device-tablet',
'device-desktop',
'touch-device',
'ios-device',
'safari-browser'
);
// Add current device class // Add current device class
body.classList.add(`device-${deviceType}`); body.classList.add(`device-${deviceType}`);
@@ -153,7 +164,7 @@ const MobileDetection = {
this._gestureStartHandler = null; this._gestureStartHandler = null;
this._gestureChangeHandler = null; this._gestureChangeHandler = null;
} }
} },
}; };
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -202,6 +213,17 @@ const KeyboardHandler = {
// Also handle scroll (iOS scrolls viewport when keyboard appears) // Also handle scroll (iOS scrolls viewport when keyboard appears)
window.visualViewport.addEventListener('scroll', this._viewportScrollHandler); window.visualViewport.addEventListener('scroll', this._viewportScrollHandler);
} }
// Prevent page-level scroll when keyboard is visible.
// iOS Safari scrolls the document to bring xterm's hidden textarea into
// view when the user types, pushing the entire UI off-screen. The CSS
// position:fixed on .app prevents most cases, but reset as a safety net.
this._windowScrollHandler = () => {
if (this.keyboardVisible) {
window.scrollTo(0, 0);
}
};
window.addEventListener('scroll', this._windowScrollHandler);
}, },
/** Remove event listeners */ /** Remove event listeners */
@@ -218,6 +240,10 @@ const KeyboardHandler = {
window.visualViewport.removeEventListener('scroll', this._viewportScrollHandler); window.visualViewport.removeEventListener('scroll', this._viewportScrollHandler);
this._viewportScrollHandler = null; this._viewportScrollHandler = null;
} }
if (this._windowScrollHandler) {
window.removeEventListener('scroll', this._windowScrollHandler);
this._windowScrollHandler = null;
}
}, },
/** Handle viewport resize (keyboard show/hide) */ /** Handle viewport resize (keyboard show/hide) */
@@ -327,6 +353,10 @@ const KeyboardHandler = {
KeyboardAccessoryBar.show(); KeyboardAccessoryBar.show();
} }
// Reset any page scroll that occurred during keyboard open.
// iOS Safari may scroll the document to reveal xterm's hidden textarea.
window.scrollTo(0, 0);
// Refit terminal locally AND send resize to server so Claude Code (Ink) // Refit terminal locally AND send resize to server so Claude Code (Ink)
// knows the actual terminal dimensions. Without this, Ink redraws at the // knows the actual terminal dimensions. Without this, Ink redraws at the
// old (larger) row count when the user types, causing content to scroll // old (larger) row count when the user types, causing content to scroll
@@ -335,11 +365,16 @@ const KeyboardHandler = {
// while keyboard is up — this one-shot resize on open/close is sufficient. // while keyboard is up — this one-shot resize on open/close is sufficient.
setTimeout(() => { setTimeout(() => {
if (typeof app !== 'undefined' && app.terminal) { if (typeof app !== 'undefined' && app.terminal) {
if (app.fitAddon) try { app.fitAddon.fit(); } catch {} if (app.fitAddon)
try {
app.fitAddon.fit();
} catch {}
app.terminal.scrollToBottom(); app.terminal.scrollToBottom();
// Send resize to server so PTY dimensions match xterm // Send resize to server so PTY dimensions match xterm
this._sendTerminalResize(); this._sendTerminalResize();
} }
// Reset again after fit/resize in case layout changes triggered scroll
window.scrollTo(0, 0);
}, 150); }, 150);
// Reposition subagent windows to stack from bottom (above keyboard) // Reposition subagent windows to stack from bottom (above keyboard)
@@ -358,7 +393,9 @@ const KeyboardHandler = {
// Refit terminal, scroll to bottom, and send resize to restore original dimensions // Refit terminal, scroll to bottom, and send resize to restore original dimensions
setTimeout(() => { setTimeout(() => {
if (typeof app !== 'undefined' && app.fitAddon) { if (typeof app !== 'undefined' && app.fitAddon) {
try { app.fitAddon.fit(); } catch {} try {
app.fitAddon.fit();
} catch {}
if (app.terminal) app.terminal.scrollToBottom(); if (app.terminal) app.terminal.scrollToBottom();
// Send resize to server to restore full terminal size // Send resize to server to restore full terminal size
this._sendTerminalResize(); this._sendTerminalResize();
@@ -381,7 +418,7 @@ const KeyboardHandler = {
fetch(`/api/sessions/${app.activeSessionId}/resize`, { fetch(`/api/sessions/${app.activeSessionId}/resize`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows }) body: JSON.stringify({ cols, rows }),
}).catch(() => {}); }).catch(() => {});
} }
} catch {} } catch {}
@@ -404,11 +441,7 @@ const KeyboardHandler = {
return false; return false;
} }
} }
return ( return tagName === 'input' || tagName === 'textarea' || el.isContentEditable;
tagName === 'input' ||
tagName === 'textarea' ||
el.isContentEditable
);
}, },
/** Scroll input into view above the keyboard */ /** Scroll input into view above the keyboard */
@@ -434,7 +467,7 @@ const KeyboardHandler = {
// For page-level - use scrollIntoView // For page-level - use scrollIntoView
input.scrollIntoView({ block: 'center', behavior: 'smooth' }); input.scrollIntoView({ block: 'center', behavior: 'smooth' });
} }
} },
}; };
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -449,8 +482,8 @@ const SwipeHandler = {
startX: 0, startX: 0,
startY: 0, startY: 0,
startTime: 0, startTime: 0,
minSwipeDistance: 80, // Minimum pixels for a valid swipe minSwipeDistance: 80, // Minimum pixels for a valid swipe
maxSwipeTime: 300, // Maximum ms for a swipe gesture maxSwipeTime: 300, // Maximum ms for a swipe gesture
maxVerticalDrift: 100, // Max vertical movement allowed maxVerticalDrift: 100, // Max vertical movement allowed
_touchStartHandler: null, _touchStartHandler: null,
@@ -501,9 +534,9 @@ const SwipeHandler = {
const deltaX = endX - this.startX; const deltaX = endX - this.startX;
const deltaY = Math.abs(endY - this.startY); const deltaY = Math.abs(endY - this.startY);
if (elapsed > this.maxSwipeTime) return; // Too slow if (elapsed > this.maxSwipeTime) return; // Too slow
if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement
if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short
// Valid swipe detected // Valid swipe detected
if (deltaX > 0) { if (deltaX > 0) {
@@ -513,5 +546,5 @@ const SwipeHandler = {
// Swipe left -> next session // Swipe left -> next session
if (typeof app !== 'undefined') app.nextSession(); if (typeof app !== 'undefined') app.nextSession();
} }
} },
}; };
+12
View File
@@ -428,6 +428,18 @@ html.mobile-init .file-browser-panel {
position: relative; position: relative;
} }
/* When keyboard is visible, pin the app container to the viewport.
iOS Safari scrolls the page to bring the focused input (xterm's hidden
textarea) into view, even with overflow:hidden. position:fixed prevents
the browser from scrolling the document under the app. */
.keyboard-visible .app {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
/* Ultra-compact session tabs — .tabs-two-rows override needed to match /* Ultra-compact session tabs — .tabs-two-rows override needed to match
specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0) */ specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0) */
.session-tabs, .session-tabs,