/** * @fileoverview Mobile device support: detection, keyboard handling, and swipe navigation. * * Defines three singleton objects that manage mobile-specific behavior: * * - MobileDetection — Device type detection (mobile/tablet/desktop), touch capability, * iOS/Safari identification, and body class management for CSS targeting. * - KeyboardHandler — Virtual keyboard show/hide detection via visualViewport API, * toolbar/accessory bar repositioning, terminal resize on keyboard open/close, * and input scroll-into-view. Uses 100px threshold for iOS address bar drift. * - SwipeHandler — Horizontal swipe detection on the terminal area for session switching. * 80px minimum distance, 300ms maximum time, 100px max vertical drift. * * All three have init()/cleanup() lifecycle methods. They are re-initialized after SSE * reconnect (in handleInit) to prevent stale closures. * * @globals {object} MobileDetection * @globals {object} KeyboardHandler * @globals {object} SwipeHandler * * @dependency keyboard-accessory.js (KeyboardAccessoryBar reference in KeyboardHandler.onKeyboardShow, soft — guarded with typeof check) * @loadorder 2 of 15 — loaded after constants.js, before voice-input.js */ // Codeman — Mobile detection, keyboard handling, and swipe navigation // Loaded after constants.js, before app.js // ═══════════════════════════════════════════════════════════════ // Mobile Detection // ═══════════════════════════════════════════════════════════════ /** * MobileDetection - Detects device type and touch capability. * Updates body classes for CSS targeting. */ const MobileDetection = { /** Check if device supports touch input */ isTouchDevice() { return ( 'ontouchstart' in window || navigator.maxTouchPoints > 0 || (window.matchMedia && window.matchMedia('(pointer: coarse)').matches) ); }, /** Check if device is iOS (iPhone, iPad, iPod) */ isIOS() { return ( /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) ); }, /** Check if browser is Safari */ isSafari() { return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); }, /** Check if screen is small (phone-sized, <430px) */ isSmallScreen() { return window.innerWidth < 430; }, /** Check if screen is medium (tablet-sized, 430-768px) */ isMediumScreen() { return window.innerWidth >= 430 && window.innerWidth < 768; }, /** Get device type based on screen width */ getDeviceType() { const width = window.innerWidth; if (width < 430) return 'mobile'; if (width < 768) return 'tablet'; return 'desktop'; }, /** Update body classes based on device detection */ updateBodyClass() { const body = document.body; const deviceType = this.getDeviceType(); const isTouch = this.isTouchDevice(); // Remove existing device classes body.classList.remove( 'device-mobile', 'device-tablet', 'device-desktop', 'touch-device', 'ios-device', 'safari-browser' ); // Add current device class body.classList.add(`device-${deviceType}`); // Add touch device class if applicable if (isTouch) { body.classList.add('touch-device'); } // Add iOS-specific class for safe area handling if (this.isIOS()) { body.classList.add('ios-device'); } // Add Safari class for browser-specific fixes if (this.isSafari()) { body.classList.add('safari-browser'); } }, /** Set --app-height CSS variable from visual viewport. * On iPad Safari with tabs, 100vh extends behind the tab bar. * visualViewport.height reflects the actual visible area. * Skips when virtual keyboard is open — KeyboardHandler manages * layout via translateY + paddingBottom; shrinking --app-height * would double-count and leave zero space for the terminal. */ updateAppHeight() { if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) return; const vh = window.visualViewport?.height || window.innerHeight; document.documentElement.style.setProperty('--app-height', `${vh}px`); }, /** Initialize mobile detection and set up resize listener */ init() { this.updateBodyClass(); this.updateAppHeight(); // Update --app-height on viewport resize (orientation, tab bar toggle) if (window.visualViewport) { this._appHeightHandler = () => this.updateAppHeight(); window.visualViewport.addEventListener('resize', this._appHeightHandler); } // Debounced resize handler let resizeTimeout; this._resizeHandler = () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { this.updateBodyClass(); this.updateAppHeight(); // Tab auto-wrap is width-driven, so it must re-evaluate on resize — the only // other trigger is a tab content render. No-op on mobile/tablet (method bails). if (typeof app !== 'undefined') app.updateTabOverflowMode?.(); }, 100); }; window.addEventListener('resize', this._resizeHandler); // iOS: prevent pinch-to-zoom (Safari ignores user-scalable=no since iOS 10) if (this.isIOS()) { this._gestureStartHandler = (e) => e.preventDefault(); this._gestureChangeHandler = (e) => e.preventDefault(); document.addEventListener('gesturestart', this._gestureStartHandler); document.addEventListener('gesturechange', this._gestureChangeHandler); } }, /** Remove event listeners */ cleanup() { if (this._resizeHandler) { window.removeEventListener('resize', this._resizeHandler); this._resizeHandler = null; } if (this._gestureStartHandler) { document.removeEventListener('gesturestart', this._gestureStartHandler); document.removeEventListener('gesturechange', this._gestureChangeHandler); this._gestureStartHandler = null; this._gestureChangeHandler = null; } }, }; // ═══════════════════════════════════════════════════════════════ // Mobile Keyboard Handler // ═══════════════════════════════════════════════════════════════ /** * KeyboardHandler - Simple handler to scroll inputs into view when keyboard appears. * Uses focusin event and scrollIntoView - keeps it simple and reliable. * Also handles terminal scrolling and toolbar repositioning via visualViewport API. */ const KeyboardHandler = { lastViewportHeight: 0, keyboardVisible: false, initialViewportHeight: 0, /** Initialize keyboard handling */ init() { // Only initialize on touch devices if (!MobileDetection.isTouchDevice()) return; this.initialViewportHeight = window.visualViewport?.height || window.innerHeight; this.lastViewportHeight = this.initialViewportHeight; // Simple focus handler - scroll input into view after keyboard appears this._focusinHandler = (e) => { const target = e.target; if (!this.isInputElement(target)) return; // Wait for keyboard animation, then scroll input into view setTimeout(() => { this.scrollInputIntoView(target); }, 400); }; document.addEventListener('focusin', this._focusinHandler); // Use visualViewport to detect keyboard and reposition toolbar if (window.visualViewport) { this._viewportResizeHandler = () => { this.handleViewportResize(); }; this._viewportScrollHandler = () => { this.updateLayoutForKeyboard(); }; window.visualViewport.addEventListener('resize', this._viewportResizeHandler); // Also handle scroll (iOS scrolls viewport when keyboard appears) 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 */ cleanup() { if (this._focusinHandler) { document.removeEventListener('focusin', this._focusinHandler); this._focusinHandler = null; } if (this._viewportResizeHandler && window.visualViewport) { window.visualViewport.removeEventListener('resize', this._viewportResizeHandler); this._viewportResizeHandler = null; } if (this._viewportScrollHandler && window.visualViewport) { window.visualViewport.removeEventListener('scroll', this._viewportScrollHandler); this._viewportScrollHandler = null; } if (this._windowScrollHandler) { window.removeEventListener('scroll', this._windowScrollHandler); this._windowScrollHandler = null; } }, /** Handle viewport resize (keyboard show/hide) */ handleViewportResize() { const currentHeight = window.visualViewport?.height || window.innerHeight; const heightDiff = this.initialViewportHeight - currentHeight; // Keyboard appeared (viewport shrunk by more than 150px) if (heightDiff > 150 && !this.keyboardVisible) { this.keyboardVisible = true; document.body.classList.add('keyboard-visible'); // While the keyboard is open, size the app to the visual viewport so // xterm's bottom row and cursor sit above the OS keyboard. document.documentElement.style.setProperty('--app-height', `${currentHeight}px`); this.onKeyboardShow(); } // Keyboard hidden (viewport grew back close to initial) // Use 100px threshold (not 50) to handle iOS address bar drift, // iOS 26's persistent 24px discrepancy, and Safari bottom bar changes else if (heightDiff < 100 && this.keyboardVisible) { this.keyboardVisible = false; document.body.classList.remove('keyboard-visible'); this.onKeyboardHide(); // Re-sync --app-height now that keyboard is gone (MobileDetection skipped // updates while keyboardVisible was true) MobileDetection.updateAppHeight(); } // Update baseline when keyboard is not visible — adapts to address bar // state changes, orientation changes, and other viewport shifts if (!this.keyboardVisible) { this.initialViewportHeight = currentHeight; } else { document.documentElement.style.setProperty('--app-height', `${currentHeight}px`); } this.updateLayoutForKeyboard(); this.lastViewportHeight = currentHeight; }, /** Update layout when keyboard shows/hides */ updateLayoutForKeyboard() { if (!window.visualViewport) return; if (!MobileDetection.isTouchDevice()) { this.resetLayout(); return; } const cjkInput = document.getElementById('cjkInput'); const isSmallMedium = MobileDetection.isSmallScreen() || MobileDetection.isMediumScreen(); if (this.keyboardVisible) { const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight); const accessoryBar = document.querySelector('.keyboard-accessory-bar'); if (isSmallMedium) { // Phones/small tablets: toolbar and accessory bar are position:fixed // via CSS. Use translateY to lift them above the keyboard. const toolbar = document.querySelector('.toolbar'); const main = document.querySelector('.main'); const layoutHeight = window.innerHeight; const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height; const keyboardOffset = Math.max(0, layoutHeight - visualBottom); if (toolbar) { toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : ''; } if (accessoryBar) { accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : ''; } if (main && keyboardHeight > 0) { const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0; main.style.paddingBottom = `${84 + cjkInputHeight}px`; } } else if (keyboardHeight > 0) { // iPad: use direct bottom positioning (translateY unreliable — // iOS auto-scrolls the visual viewport, making keyboardOffset ≈ 0). if (accessoryBar) { accessoryBar.style.bottom = `${keyboardHeight}px`; } } // CJK textarea positioning (always position:fixed on touch devices). if (cjkInput?.classList.contains('cjk-input-visible') && keyboardHeight > 0) { if (isSmallMedium) { // Phones: use translateY like toolbar/accessory bar. const layoutHeight = window.innerHeight; const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height; const keyboardOffset = Math.max(0, layoutHeight - visualBottom); cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : ''; cjkInput.style.bottom = ''; } else { // iPad: direct bottom = keyboard + accessory bar height. cjkInput.style.bottom = `${keyboardHeight + 44}px`; cjkInput.style.transform = ''; } } } else { this.resetLayout(); } }, /** Reset layout to normal (no keyboard) */ resetLayout() { const toolbar = document.querySelector('.toolbar'); const accessoryBar = document.querySelector('.keyboard-accessory-bar'); const cjkInput = document.getElementById('cjkInput'); const main = document.querySelector('.main'); if (toolbar) { toolbar.style.transform = ''; } if (accessoryBar) { accessoryBar.style.transform = ''; accessoryBar.style.bottom = ''; } if (cjkInput) { cjkInput.style.transform = ''; cjkInput.style.bottom = ''; } if (main) { main.style.paddingBottom = ''; } }, /** Called when keyboard appears */ onKeyboardShow() { // Show keyboard accessory bar if (typeof KeyboardAccessoryBar !== 'undefined') { 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) // knows the actual terminal dimensions. Without this, Ink redraws at the // old (larger) row count when the user types, causing content to scroll // off the visible area with each keystroke. // Note: the throttledResize handler still suppresses ongoing resize events // while keyboard is up — this one-shot resize on open/close is sufficient. setTimeout(() => { if (typeof app !== 'undefined' && app.terminal) { if (app.fitAddon) try { app.fitAddon.fit(); } catch {} // Eliminate terminal row quantization gap: xterm can only show whole // rows, so leftover pixels create dead space below the last row. // Shrink .main's paddingBottom by the gap so the terminal fills flush // to the accessory bar. this._shrinkPaddingToFit(); app.terminal.scrollToBottom(); app._syncMobileHelperTextareaToCursor?.(); app._localEchoOverlay?.rerender?.(); // Send resize to server so PTY dimensions match xterm this._sendTerminalResize(); } // Reset again after fit/resize in case layout changes triggered scroll window.scrollTo(0, 0); }, 150); // Reposition subagent windows to stack from bottom (above keyboard) if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows(); }, /** Called when keyboard hides */ onKeyboardHide() { // Hide keyboard accessory bar if (typeof KeyboardAccessoryBar !== 'undefined') { KeyboardAccessoryBar.hide(); } this.resetLayout(); // Refit terminal, scroll to bottom, and send resize to restore original dimensions setTimeout(() => { if (typeof app !== 'undefined' && app.fitAddon) { try { app.fitAddon.fit(); } catch {} if (app.terminal) app.terminal.scrollToBottom(); // Send resize to server to restore full terminal size this._sendTerminalResize(); } }, 100); // Reposition subagent windows to stack from top (below header) if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows(); }, /** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */ _sendTerminalResize() { if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return; try { const dims = app.fitAddon.proposeDimensions(); if (dims) { const cols = Math.max(dims.cols, 40); const rows = Math.max(dims.rows, 10); app._lastResizeDims = { cols, rows }; // Declare the viewport type so resize arbitration can ignore this // while a desktop connection is sizing the same session. const viewportType = MobileDetection.getDeviceType ? MobileDetection.getDeviceType() : 'mobile'; fetch(`/api/sessions/${app.activeSessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols, rows, viewportType }), }).catch(() => {}); } } catch {} }, /** * Shrink .main paddingBottom to eliminate the terminal row quantization gap. * xterm can only render whole rows, so fractional-row pixels create dead * space below the last row. After fitAddon.fit(), measure the gap and * reduce padding by that amount so the terminal sits flush against the bars. */ _shrinkPaddingToFit() { try { const container = document.getElementById('terminalContainer'); const main = document.querySelector('.main'); if (!container || !main || typeof app === 'undefined' || !app.terminal) return; const cellH = app.terminal._core?._renderService?.dimensions?.css?.cell?.height; if (!cellH) return; const gap = container.clientHeight - app.terminal.rows * cellH; if (gap > 0 && gap < cellH) { const currentPadding = parseInt(main.style.paddingBottom) || 0; main.style.paddingBottom = Math.max(0, currentPadding - gap) + 'px'; if (app.fitAddon) try { app.fitAddon.fit(); } catch {} } } catch {} }, /** Check if element is an input that triggers keyboard (excludes terminal) */ isInputElement(el) { if (!el) return false; // Exclude xterm.js terminal inputs (they handle their own scroll) if (el.closest('.xterm') || el.closest('.terminal-container')) { return false; } const tagName = el.tagName?.toLowerCase(); // Exclude type=range, type=checkbox, type=radio (don't trigger keyboard) if (tagName === 'input') { const type = el.type?.toLowerCase(); if (type === 'checkbox' || type === 'radio' || type === 'range' || type === 'file') { return false; } } return tagName === 'input' || tagName === 'textarea' || el.isContentEditable; }, /** Scroll input into view above the keyboard */ scrollInputIntoView(input) { // Check if input is still focused (user might have tapped away) if (document.activeElement !== input) return; // Find if we're in a modal const modal = input.closest('.modal.active'); const modalBody = modal?.querySelector('.modal-body'); if (modalBody) { // For modals - scroll within the modal body const inputRect = input.getBoundingClientRect(); const modalRect = modalBody.getBoundingClientRect(); // If input is below middle of modal, scroll it up if (inputRect.top > modalRect.top + modalRect.height * 0.4) { const scrollAmount = inputRect.top - modalRect.top - 100; modalBody.scrollBy({ top: scrollAmount, behavior: 'smooth' }); } } else { // For page-level - use scrollIntoView input.scrollIntoView({ block: 'center', behavior: 'smooth' }); } }, }; // ═══════════════════════════════════════════════════════════════ // Mobile Swipe Handler // ═══════════════════════════════════════════════════════════════ /** * SwipeHandler - Detects horizontal swipes on terminal to switch sessions. * Only active on mobile/touch devices. */ const SwipeHandler = { startX: 0, startY: 0, startTime: 0, minSwipeDistance: 80, // Minimum pixels for a valid swipe maxSwipeTime: 300, // Maximum ms for a swipe gesture maxVerticalDrift: 100, // Max vertical movement allowed _touchStartHandler: null, _touchEndHandler: null, _element: null, /** Initialize swipe handling */ init() { // Only on touch devices if (!MobileDetection.isTouchDevice()) return; const terminal = document.querySelector('.main'); if (!terminal) return; this._element = terminal; this._touchStartHandler = (e) => this.onTouchStart(e); this._touchEndHandler = (e) => this.onTouchEnd(e); terminal.addEventListener('touchstart', this._touchStartHandler, { passive: true }); terminal.addEventListener('touchend', this._touchEndHandler, { passive: true }); }, /** Remove swipe listeners */ cleanup() { if (this._element && this._touchStartHandler) { this._element.removeEventListener('touchstart', this._touchStartHandler); this._element.removeEventListener('touchend', this._touchEndHandler); } this._touchStartHandler = null; this._touchEndHandler = null; this._element = null; }, onTouchStart(e) { if (!e.touches || e.touches.length !== 1) return; this.startX = e.touches[0].clientX; this.startY = e.touches[0].clientY; this.startTime = Date.now(); }, onTouchEnd(e) { if (!e.changedTouches || e.changedTouches.length !== 1) return; const endX = e.changedTouches[0].clientX; const endY = e.changedTouches[0].clientY; const elapsed = Date.now() - this.startTime; // Check if it's a valid swipe const deltaX = endX - this.startX; const deltaY = Math.abs(endY - this.startY); if (elapsed > this.maxSwipeTime) return; // Too slow if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short // Valid swipe detected if (deltaX > 0) { // Swipe right -> previous session if (typeof app !== 'undefined') app.prevSession(); } else { // Swipe left -> next session if (typeof app !== 'undefined') app.nextSession(); } }, };