refactor: split app.js into focused frontend modules (phase 5)

Extract 8 modules from the 15K-line app.js monolith:
- constants.js: shared constants, timing, escapeHtml(), extractSyncSegments()
- mobile-handlers.js: MobileDetection, KeyboardHandler, SwipeHandler
- voice-input.js: DeepgramProvider, VoiceInput
- notification-manager.js: NotificationManager class
- keyboard-accessory.js: KeyboardAccessoryBar, FocusTrap
- api-client.js: _api(), _apiJson(), _apiPost(), _apiDelete() helpers
- subagent-windows.js: 13 subagent window methods (open, close, drag, lines)
- vendor/xterm-zerolag-input.js: IIFE build replacing inlined copy

app.js reduced from ~15,200 to ~11,500 lines. All modules use global
scope with <script defer> ordering. Prototype extensions use
Object.assign(CodemanApp.prototype, {...}) pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-03-01 04:57:37 +01:00
co-authored by Claude Opus 4.6
parent e7a9cbe442
commit f44f0a912f
14 changed files with 4660 additions and 3800 deletions
+449
View File
@@ -0,0 +1,449 @@
// 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');
}
},
/** Initialize mobile detection and set up resize listener */
init() {
this.updateBodyClass();
// Debounced resize handler
let resizeTimeout;
this._resizeHandler = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => this.updateBodyClass(), 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);
}
},
/** 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;
}
},
/** 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');
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();
}
// 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;
}
this.updateLayoutForKeyboard();
this.lastViewportHeight = currentHeight;
},
/** Update layout when keyboard shows/hides */
updateLayoutForKeyboard() {
if (!window.visualViewport) return;
// Only adjust on mobile
if (!MobileDetection.isSmallScreen() && !MobileDetection.isMediumScreen()) {
this.resetLayout();
return;
}
const toolbar = document.querySelector('.toolbar');
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
const main = document.querySelector('.main');
if (this.keyboardVisible) {
// Calculate keyboard offset
const layoutHeight = window.innerHeight;
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
const keyboardOffset = layoutHeight - visualBottom;
// Safety: if keyboard is supposedly visible but offset is 0 or negative,
// the keyboard is actually gone — force dismiss. This catches cases where
// visualViewport.resize fires late or with intermediate values on iOS.
if (keyboardOffset <= 0) {
this.keyboardVisible = false;
document.body.classList.remove('keyboard-visible');
this.onKeyboardHide();
return;
}
// Move toolbar up above keyboard
if (toolbar) {
toolbar.style.transform = `translateY(${-keyboardOffset}px)`;
}
// Move accessory bar up (it sits above toolbar)
if (accessoryBar) {
accessoryBar.style.transform = `translateY(${-keyboardOffset}px)`;
}
// Shrink main content area so terminal doesn't extend behind keyboard
// Account for keyboard height + toolbar height (40px) + accessory bar (44px)
if (main) {
main.style.paddingBottom = `${keyboardOffset + 94}px`;
}
} else {
this.resetLayout();
}
},
/** Reset layout to normal (no keyboard) */
resetLayout() {
const toolbar = document.querySelector('.toolbar');
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
const main = document.querySelector('.main');
if (toolbar) {
toolbar.style.transform = '';
}
if (accessoryBar) {
accessoryBar.style.transform = '';
}
if (main) {
main.style.paddingBottom = '';
}
},
/** Called when keyboard appears */
onKeyboardShow() {
// Show keyboard accessory bar
if (typeof KeyboardAccessoryBar !== 'undefined') {
KeyboardAccessoryBar.show();
}
// 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 {}
app.terminal.scrollToBottom();
// Send resize to server so PTY dimensions match xterm
this._sendTerminalResize();
}
}, 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 };
fetch(`/api/sessions/${app.activeSessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows })
}).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
/** Initialize swipe handling */
init() {
// Only on touch devices
if (!MobileDetection.isTouchDevice()) return;
const terminal = document.querySelector('.main');
if (!terminal) return;
terminal.addEventListener('touchstart', (e) => this.onTouchStart(e), { passive: true });
terminal.addEventListener('touchend', (e) => this.onTouchEnd(e), { passive: true });
},
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();
}
}
};