fix: mobile keyboard layout — scroll-to-bottom on show, robust dismiss detection

Three bugs fixed in KeyboardHandler:
1. onKeyboardShow() used sessionData.terminal (always undefined) instead of app.terminal
2. Keyboard dismiss threshold (50px) too tight for iOS address bar drift — widened to 100px
3. No terminal refit on keyboard hide — caused black gap below terminal

Also added: dynamic baseline update for initialViewportHeight, safety check in
updateLayoutForKeyboard() that forces dismiss when keyboardOffset <= 0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-16 12:10:44 +01:00
co-authored by Claude Opus 4.6
parent bd6d777294
commit da74ac248b
7 changed files with 1281 additions and 20 deletions
+32 -7
View File
@@ -250,12 +250,20 @@ const KeyboardHandler = {
this.onKeyboardShow();
}
// Keyboard hidden (viewport grew back close to initial)
else if (heightDiff < 50 && this.keyboardVisible) {
// 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;
},
@@ -280,6 +288,16 @@ const KeyboardHandler = {
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)`;
@@ -325,14 +343,14 @@ const KeyboardHandler = {
}
// Scroll active terminal to bottom so input line is visible
// Delay to let layout settle after keyboard resizes the viewport
setTimeout(() => {
if (typeof app !== 'undefined' && app.activeSessionId) {
const sessionData = app.sessions.get(app.activeSessionId);
if (sessionData?.terminal) {
sessionData.terminal.scrollToBottom();
}
if (typeof app !== 'undefined' && app.terminal) {
// Refit terminal to the now-smaller container, then scroll
if (app.fitAddon) try { app.fitAddon.fit(); } catch {}
app.terminal.scrollToBottom();
}
}, 100);
}, 150);
},
/** Called when keyboard hides */
@@ -343,6 +361,13 @@ const KeyboardHandler = {
}
this.resetLayout();
// Refit terminal to the restored (larger) container to prevent black gap
setTimeout(() => {
if (typeof app !== 'undefined' && app.fitAddon) {
try { app.fitAddon.fit(); } catch {}
}
}, 100);
},
/** Check if element is an input that triggers keyboard (excludes terminal) */
+943
View File
@@ -0,0 +1,943 @@
/* ============================================================================
Mobile & Touch Styles
Extracted from styles.css for cleaner separation.
Loaded only on small/medium screens via media attribute in index.html.
============================================================================ */
/* ============================================================================
Tablet Breakpoint (430px - 768px)
============================================================================ */
@media (max-width: 768px) and (min-width: 430px) {
/* Compact header for tablet - fixed at top, includes safe area padding */
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
min-height: 48px;
max-height: 48px;
padding: 0.35rem 0.5rem;
padding-left: calc(0.5rem + var(--safe-area-left));
padding-right: calc(0.5rem + var(--safe-area-right));
background: #0a0a0a;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
z-index: 200;
}
/* iOS safe area adjustment for fixed header - header extends into notch area */
.ios-device .header {
padding-top: calc(0.35rem + var(--safe-area-top));
min-height: calc(48px + var(--safe-area-top));
max-height: calc(48px + var(--safe-area-top));
}
/* Add top margin to main content to account for fixed header */
.main {
margin-top: 54px;
}
.ios-device .main {
margin-top: calc(54px + var(--safe-area-top));
}
/* Font controls - smaller on tablet, visibility controlled by JS */
.header-font-controls {
gap: 0.15rem;
padding: 0.15rem 0.25rem;
}
.header-font-controls .btn-icon-sm {
width: 18px;
height: 18px;
}
/* System stats - compact on tablet, visibility controlled by JS */
.header-system-stats {
font-size: 0.6rem;
gap: 0.25rem;
}
.header-right {
gap: 0.35rem;
}
/* Compact session tabs */
.session-tabs {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
max-height: 52px;
gap: 3px;
}
.session-tabs::-webkit-scrollbar {
display: none;
}
.session-tab {
padding: 0.4rem 0.6rem;
font-size: 0.75rem;
min-height: 40px;
}
.session-tab .tab-name {
max-width: 80px;
}
/* Compact toolbar - includes safe area padding */
.toolbar {
padding: 0.4rem 0.5rem;
padding-left: calc(0.5rem + var(--safe-area-left));
padding-right: calc(0.5rem + var(--safe-area-right));
gap: 0.4rem;
}
/* Hide instance count buttons on tablet too */
.tab-count-group {
display: none;
}
/* Compact toolbar buttons */
.btn-toolbar {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
min-height: 40px;
}
.case-select-group {
max-width: 150px;
}
.toolbar-select {
font-size: 0.75rem;
}
/* Stack panels vertically */
.monitor-panel,
.subagents-panel {
width: 100%;
max-width: 100%;
left: 0;
right: 0;
border-radius: 8px 8px 0 0;
max-height: 40vh;
}
/* Project insights - compact on tablet, visibility controlled by JS */
.project-insights-panel {
max-width: 280px;
font-size: 0.7rem;
}
}
/* ============================================================================
Phone Breakpoint (<430px)
============================================================================ */
@media (max-width: 430px) {
/* Hide header brand on phones */
.header-brand {
display: none;
}
/* Font controls - compact on phones, visibility controlled by JS */
.header-font-controls {
gap: 0.1rem;
padding: 0.1rem 0.2rem;
}
.header-font-controls .btn-icon-sm {
width: 16px;
height: 16px;
}
.header-font-controls .font-size-display {
font-size: 0.6rem;
min-width: 14px;
}
/* System stats - very compact on phones, visibility controlled by JS */
.header-system-stats {
font-size: 0.55rem;
gap: 0.15rem;
}
/* Token count - compact on phones, visibility controlled by JS */
.header-tokens {
font-size: 0.6rem;
padding: 0.1rem 0.3rem;
}
/* Ultra-compact header for mobile - fixed at top */
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
min-height: 36px;
max-height: 36px;
padding: 0.15rem 0.3rem;
padding-left: calc(0.3rem + var(--safe-area-left));
padding-right: calc(0.3rem + var(--safe-area-right));
gap: 0.15rem;
overflow: hidden;
background: #0a0a0a;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
z-index: 200;
}
/* iOS safe area adjustment for fixed header - header extends into notch area */
.ios-device .header {
padding-top: calc(0.15rem + var(--safe-area-top));
min-height: calc(36px + var(--safe-area-top));
max-height: calc(36px + var(--safe-area-top));
}
/* Add top margin to main content to account for fixed header */
.main {
margin-top: 42px;
}
.ios-device .main {
margin-top: calc(42px + var(--safe-area-top));
}
.header-right {
padding-left: 0.2rem;
gap: 0.1rem;
flex-shrink: 0;
border-left: none;
}
/* Smaller header buttons on mobile */
.btn-icon-header {
width: 26px;
height: 26px;
padding: 0;
}
.btn-icon-header svg {
width: 12px;
height: 12px;
}
/* Larger settings gear icon for visibility */
.btn-icon-header.btn-settings {
width: 32px;
height: 32px;
}
.btn-icon-header.btn-settings svg {
width: 16px;
height: 16px;
}
/* App container - prevent scrolling, strict layout */
.app {
overflow: hidden;
position: relative;
}
/* Ultra-compact session tabs */
.session-tabs {
flex: 1;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
max-height: 36px;
gap: 2px;
padding: 0;
}
.session-tabs::-webkit-scrollbar {
display: none;
}
/* Smaller tabs for mobile */
.session-tab {
flex-shrink: 0;
min-height: 32px;
max-height: 32px;
padding: 0.35rem 0.5rem;
font-size: 0.7rem;
gap: 0.25rem;
border-radius: 4px;
}
/* Smaller status indicator on mobile */
.session-tab .tab-status {
width: 4px;
height: 4px;
}
/* Truncate tab names more aggressively on mobile */
.session-tab .tab-name {
max-width: 50px;
overflow: hidden;
text-overflow: ellipsis;
}
/* Hide close/gear buttons on non-active tabs on mobile */
.session-tab .tab-close,
.session-tab .tab-gear {
display: none;
}
/* Gear icon on active tab - tiny, subtle */
.session-tab.active .tab-gear {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.5rem;
line-height: 1;
width: 12px;
height: 12px;
margin-left: auto;
opacity: 0.6;
}
/* Close button on active tab - large for easy tapping */
.session-tab.active .tab-close {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
line-height: 1;
width: 20px;
height: 18px;
margin-left: -2px;
}
/* Clean dark toolbar - positioned near bottom */
.toolbar {
position: fixed;
bottom: var(--safe-area-bottom);
left: 0;
right: 0;
height: 40px;
min-height: 40px;
max-height: 40px;
padding: 0.3rem 0.4rem;
padding-left: calc(0.4rem + var(--safe-area-left));
padding-right: calc(0.4rem + var(--safe-area-right));
flex-wrap: nowrap;
gap: 0.3rem;
align-items: center;
justify-content: space-between;
background: #111111;
border-top: 1px solid rgba(255, 255, 255, 0.1);
z-index: 50;
/* Smooth transition for keyboard show/hide */
transition: transform 0.15s ease-out;
/* Ensure transform works properly */
will-change: transform;
}
/* Show case selector in center */
.toolbar-center {
display: flex !important;
align-items: center;
}
.toolbar-right {
display: none !important;
}
/* Hide version display on mobile */
.version-display {
display: none !important;
}
/* Toolbar layout */
.toolbar-left {
flex: 0 0 auto;
align-items: center;
gap: 0.3rem;
}
.toolbar-left .toolbar-group {
display: flex;
align-items: center;
gap: 0.3rem;
}
.toolbar-left .toolbar-group:first-child {
flex: 1;
justify-content: space-between;
gap: 0.3rem;
flex-wrap: nowrap;
align-items: center;
}
/* Hide instance count buttons (±) on mobile - cleaner UI */
.tab-count-group {
display: none !important;
}
/* Compact toolbar buttons for mobile - white text on dark */
.btn-toolbar {
min-height: 26px !important;
max-height: 26px !important;
height: 26px !important;
padding: 0 0.5rem !important;
font-size: 0.65rem !important;
border-radius: 4px;
line-height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 500;
letter-spacing: 0.01em;
color: #fff;
}
/* Primary action button - Run Claude */
.btn-toolbar.btn-claude {
flex: 0 0 auto;
padding: 0 0.75rem !important;
background: var(--bg-input);
border: 1px solid var(--border-light);
color: var(--text);
font-weight: 500;
}
.btn-toolbar.btn-claude:hover,
.btn-toolbar.btn-claude:active {
background: var(--bg-hover);
border-color: var(--accent);
}
.btn-toolbar.btn-claude svg {
width: 10px;
height: 10px;
margin-right: 4px;
}
/* Secondary action - Run Shell - right side */
.btn-toolbar.btn-shell {
flex: 0 0 auto;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: #9ca3af;
order: 3; /* Right position */
}
.btn-toolbar.btn-shell:hover,
.btn-toolbar.btn-shell:active {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
/* Hide case selector on mobile - simplified toolbar */
.case-select-group {
display: none !important;
}
/* Simplified toolbar layout - Run Claude, Shell, and Case */
.toolbar-left .toolbar-group:first-child {
width: 100%;
justify-content: center;
gap: 8px;
}
.btn-toolbar.btn-claude {
flex: 0 0 auto;
min-width: fit-content;
white-space: nowrap;
padding: 0 10px !important;
}
.btn-toolbar.btn-shell {
flex: 0 0 auto;
min-width: fit-content;
white-space: nowrap;
padding: 0 10px !important;
}
/* Mobile case button - visible on mobile */
.btn-toolbar.btn-case-mobile {
display: inline-flex !important;
flex: 0 0 auto;
min-width: fit-content;
white-space: nowrap;
padding: 0 8px !important;
order: 2; /* Between Run Claude and Run Shell */
}
.btn-toolbar.btn-case-mobile #mobileCaseName {
max-width: 50px;
}
/* Keyboard Accessory Bar - quick actions above keyboard */
.keyboard-accessory-bar {
display: none;
position: fixed;
bottom: calc(var(--safe-area-bottom) + 40px); /* Above toolbar */
left: 0;
right: 0;
height: 44px;
background: #1a1a1a;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding: 6px 8px;
padding-left: calc(8px + var(--safe-area-left));
padding-right: calc(8px + var(--safe-area-right));
gap: 8px;
align-items: center;
justify-content: center;
z-index: 51;
transition: transform 0.15s ease-out;
will-change: transform;
}
.keyboard-accessory-bar.visible {
display: flex;
}
.accessory-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 12px;
background: #2a2a2a;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #e5e5e5;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: background 0.1s;
}
.accessory-btn:active {
background: #3a3a3a;
}
.accessory-btn svg {
width: 14px;
height: 14px;
}
.accessory-btn-dismiss {
margin-left: auto;
padding: 6px 10px;
background: transparent;
border-color: transparent;
color: #888;
}
/* Add case button - compact (keeping for when shown via menu) */
.btn-case-add {
display: none !important;
}
.btn-case-settings {
display: none !important;
}
/* When keyboard is visible, also move accessory bar up */
.keyboard-visible .keyboard-accessory-bar.visible {
/* Position is handled by JS transform along with toolbar */
}
/* Paste overlay for iOS clipboard access */
.paste-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 10000;
display: flex;
align-items: flex-end;
justify-content: center;
padding-bottom: env(safe-area-inset-bottom, 12px);
}
.paste-dialog {
background: var(--bg-secondary, #1e1e2e);
border: 1px solid var(--border-color, #444);
border-radius: 12px;
padding: 12px;
width: calc(100% - 24px);
max-width: 400px;
margin-bottom: 8px;
}
.paste-textarea {
width: 100%;
min-height: 100px;
background: var(--bg-primary, #0d0d14);
color: var(--text-primary, #e0e0e0);
border: 1px solid var(--border-color, #444);
border-radius: 8px;
padding: 10px;
font-family: inherit;
font-size: 14px;
resize: vertical;
box-sizing: border-box;
}
.paste-textarea:focus {
outline: none;
border-color: var(--accent-color, #7aa2f7);
}
.paste-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
.paste-cancel, .paste-send {
padding: 8px 18px;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.paste-cancel {
background: var(--bg-tertiary, #333);
color: var(--text-secondary, #aaa);
}
.paste-send {
background: var(--accent-color, #7aa2f7);
color: #fff;
font-weight: 600;
}
/* LEGACY: Hide old toolbar select (no longer used on mobile) */
.toolbar-select {
display: none !important;
}
/* Keep btn-case-add styling for tablet/future use */
.toolbar .btn-case-add {
min-width: 26px !important;
max-width: 26px !important;
width: 26px !important;
min-height: 26px !important;
max-height: 26px !important;
height: 26px !important;
padding: 0 !important;
font-size: 0.9rem;
font-weight: bold;
display: inline-flex !important;
align-items: center;
justify-content: center;
line-height: 1;
border-radius: 4px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.15);
color: #9ca3af;
}
.btn-case-add:hover,
.btn-case-add:active {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
/* Panels on mobile - full width, positioned above toolbar, visibility controlled by JS */
.monitor-panel,
.subagents-panel {
width: 100%;
max-width: 100%;
left: 0;
right: 0;
border-radius: 8px 8px 0 0;
bottom: calc(44px + 2rem + var(--safe-area-bottom));
max-height: 35vh;
}
/* Full-screen modals on phones */
.modal-content {
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
border-radius: 0;
margin: 0;
}
/* Modal safe area padding - all sides for full-screen modals */
.ios-device .modal-content {
padding-top: var(--safe-area-top);
padding-bottom: var(--safe-area-bottom);
padding-left: var(--safe-area-left);
padding-right: var(--safe-area-right);
}
.modal-header {
padding: 0.75rem 1rem;
}
.modal-header h3 {
font-size: 1rem;
}
.modal-body {
padding: 0.75rem 1rem;
flex: 1;
overflow-y: auto;
}
.modal-footer,
.form-actions {
padding: 0.75rem 1rem;
padding-bottom: calc(0.75rem + var(--safe-area-bottom));
}
/* Terminal container safe area */
.ios-device .terminal-container {
padding-bottom: var(--safe-area-bottom);
}
/* Main area must fill available space */
.main {
flex: 1;
min-height: 0; /* Allow flex item to shrink below content size */
}
/* Show CLI info bar on mobile (Claude version/model) */
.cli-info-bar {
display: block;
}
/* Mobile terminal - native touch scrolling via xterm-viewport */
.terminal-container {
height: 100%;
min-height: 0;
position: relative;
overflow: visible; /* Must not be hidden — blocks touch scroll on viewport */
touch-action: pan-y;
}
.terminal-container .xterm {
touch-action: pan-y;
}
/* xterm-viewport is the scrollable element inside xterm.js.
It must sit above xterm-screen to receive touch events.
See: https://github.com/xtermjs/xterm.js/issues/5377 */
.terminal-container .xterm-viewport {
position: absolute !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
z-index: 10 !important;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
overflow-y: scroll !important;
}
.terminal-container .xterm-screen {
touch-action: pan-y;
}
/* Compact welcome overlay for mobile */
.welcome-content {
padding: 1rem 0.75rem;
}
.welcome-title {
font-size: 1.2rem;
margin-bottom: 0.5rem;
}
.welcome-desc {
font-size: 0.8rem;
margin-bottom: 0.4rem;
}
.welcome-actions {
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
}
.welcome-btn {
width: 100%;
justify-content: center;
min-height: 44px;
padding: 0.75rem 1rem;
font-size: 0.85rem;
}
.welcome-hint {
font-size: 0.7rem;
margin-top: 0.75rem;
}
/* Ralph wizard responsive */
.modal-wizard {
display: flex;
flex-direction: column;
}
.wizard-progress {
padding: 0.5rem;
}
.wizard-step {
padding: 0.3rem;
}
.wizard-step-number {
width: 24px;
height: 24px;
font-size: 0.7rem;
}
.wizard-step-label {
display: none;
}
/* Timer banner - includes safe area padding */
.timer-banner {
padding-left: calc(0.5rem + var(--safe-area-left));
padding-right: calc(0.5rem + var(--safe-area-right));
}
/* Respawn/Ralph panels - more compact on mobile, includes safe area padding */
.respawn-banner {
padding: 0.4rem 0.5rem;
padding-left: calc(0.5rem + var(--safe-area-left));
padding-right: calc(0.5rem + var(--safe-area-right));
font-size: 0.7rem;
}
.ralph-panel {
font-size: 0.75rem;
}
.ralph-summary {
padding: 0.4rem 0.5rem;
padding-left: calc(0.5rem + var(--safe-area-left));
padding-right: calc(0.5rem + var(--safe-area-right));
}
/* Project insights panel - compact on mobile, visibility controlled by JS */
.project-insights-panel {
max-width: 100%;
font-size: 0.65rem;
bottom: calc(44px + 2rem + var(--safe-area-bottom));
}
/* File browser panel - compact on mobile, visibility controlled by JS */
.file-browser-panel {
max-width: 100%;
max-height: 50vh;
bottom: calc(44px + 2rem + var(--safe-area-bottom));
}
/* Notification drawer - full width on mobile, includes safe area padding */
.notification-drawer {
width: 100%;
max-width: 100%;
right: 0;
border-radius: 0;
padding-left: var(--safe-area-left);
padding-right: var(--safe-area-right);
padding-bottom: var(--safe-area-bottom);
}
/* Scroll margin for inputs - helps with keyboard visibility */
input, textarea, [contenteditable] {
scroll-margin-bottom: 200px;
scroll-margin-top: 80px;
}
/* When keyboard is visible, adjust content to account for moved toolbar */
.keyboard-visible .terminal-container {
/* Reduce terminal height when keyboard is up so it doesn't overlap toolbar */
padding-bottom: 50px;
}
/* Ensure modals scroll properly when keyboard is visible */
.keyboard-visible .modal-body {
max-height: 40vh;
overflow-y: auto;
}
/* Mobile Case Picker - Mobile-specific overrides */
.mobile-case-picker .modal-backdrop {
background: rgba(0, 0, 0, 0.5);
}
.mobile-case-picker-sheet {
max-height: 60vh;
padding-bottom: var(--safe-area-bottom);
animation: slideUp 0.2s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.mobile-case-picker-header .modal-close {
width: 32px;
height: 32px;
font-size: 1.5rem;
}
.mobile-case-picker-footer {
padding-bottom: calc(12px + var(--safe-area-bottom));
}
}
/* ============================================================================
iOS Safari Specific Fixes
============================================================================ */
.ios-device.safari-browser {
/* Prevent pull-to-refresh */
overscroll-behavior: none;
}
.ios-device.safari-browser .terminal-container {
/* Keep momentum scrolling but prevent page bounce */
-webkit-overflow-scrolling: touch;
}
.ios-device.safari-browser .terminal-container .xterm-viewport {
-webkit-overflow-scrolling: touch;
}
/* ============================================================================
Hover State Fallbacks for Touch
============================================================================ */
@media (hover: none) and (pointer: coarse) {
/* Remove hover-dependent UI patterns */
.session-tab .tab-close,
.session-tab .tab-gear {
opacity: 1;
width: auto;
padding: 0.15rem 0.25rem;
align-items: center;
justify-content: center;
}
/* Simplify button hover states */
.btn-toolbar:hover {
transform: none;
}
.btn-icon-header:hover {
transform: none;
}
/* Show dropdown on tap instead of hover */
.subagent-dropdown-trigger:active + .subagent-dropdown-menu,
.subagent-dropdown-trigger:focus + .subagent-dropdown-menu {
display: block;
}
}
+12 -11
View File
@@ -8782,31 +8782,32 @@ kbd {
display: block;
}
/* Mobile terminal - use native browser scrolling for smooth performance */
/* Mobile terminal - native touch scrolling via xterm-viewport */
.terminal-container {
height: 100%;
min-height: 0;
position: relative;
overflow: hidden;
contain: layout style;
touch-action: pan-y; /* Allow native vertical scrolling */
overflow: visible; /* Must not be hidden — blocks touch scroll on viewport */
touch-action: pan-y;
}
.terminal-container .xterm {
touch-action: pan-y;
}
/* Viewport scroll optimization - use native scrolling */
/* CRITICAL: xterm-viewport must be above xterm-screen for touch scrolling to work */
/* See: https://github.com/xtermjs/xterm.js/issues/5377 */
/* xterm-viewport is the scrollable element inside xterm.js.
It must sit above xterm-screen to receive touch events.
See: https://github.com/xtermjs/xterm.js/issues/5377 */
.terminal-container .xterm-viewport {
position: absolute !important;
z-index: 10 !important; /* Above xterm-screen so touch events hit the scrollable viewport */
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
z-index: 10 !important;
touch-action: pan-y;
-webkit-overflow-scrolling: touch; /* Enable iOS momentum scrolling */
-webkit-overflow-scrolling: touch;
overflow-y: scroll !important;
scroll-behavior: auto !important;
will-change: scroll-position;
}
.terminal-container .xterm-screen {
+155
View File
@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Upload Screenshot - Claudeman</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #0d1117; color: #c9d1d9;
min-height: 100vh; min-height: 100dvh;
display: flex; align-items: center; justify-content: center;
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
-webkit-text-size-adjust: 100%;
}
.container { max-width: 480px; width: 100%; padding: 24px; }
h1 { font-size: 1.4em; margin-bottom: 16px; color: #58a6ff; }
.drop-zone {
border: 2px dashed #30363d; border-radius: 12px;
padding: 48px 20px; text-align: center;
-webkit-tap-highlight-color: transparent;
transition: border-color 0.2s, background 0.2s;
}
.drop-zone.active { border-color: #58a6ff; background: #161b22; }
.drop-zone p { margin-bottom: 12px; font-size: 1.1em; }
.drop-zone small { color: #8b949e; }
input[type=file] { display: none; }
.preview { margin-top: 16px; text-align: center; display: none; }
.preview img { max-width: 100%; max-height: 300px; border-radius: 8px; border: 1px solid #30363d; }
.preview .name { margin-top: 6px; font-size: 0.85em; color: #8b949e; word-break: break-all; }
button {
width: 100%; padding: 14px; margin-top: 16px;
background: #238636; color: #fff; border: none;
border-radius: 8px; font-size: 1.05em;
font-weight: 600; -webkit-appearance: none;
opacity: 0.4; pointer-events: none;
}
button.ready { opacity: 1; pointer-events: auto; }
button:active { background: #2ea043; }
.status { margin-top: 12px; padding: 10px; border-radius: 6px; text-align: center; display: none; }
.status.ok { display: block; background: #1a3a2a; color: #3fb950; }
.status.err { display: block; background: #3a1a1a; color: #f85149; }
.files { margin-top: 24px; }
.files h2 { font-size: 0.95em; color: #8b949e; margin-bottom: 8px; }
.files a { display: block; color: #58a6ff; text-decoration: none; padding: 6px 0; font-size: 0.9em; word-break: break-all; }
.files a:active { color: #79c0ff; }
.back { display: inline-block; margin-bottom: 12px; color: #8b949e; text-decoration: none; font-size: 0.9em; }
.back:active { color: #c9d1d9; }
</style>
</head><body>
<div class="container">
<a class="back" href="/">&larr; Back to Claudeman</a>
<h1>Upload Screenshot</h1>
<div class="drop-zone" id="drop">
<p>Tap to select image</p>
<small>PNG, JPG, WebP &mdash; up to 10 MB</small>
</div>
<input type="file" id="file" accept="image/*">
<div class="preview" id="preview">
<img id="previewImg" alt="Preview">
<div class="name" id="previewName"></div>
</div>
<button id="btn">Upload</button>
<div class="status" id="status"></div>
<div class="files" id="files"></div>
</div>
<script>
(function() {
var drop = document.getElementById('drop');
var fileInput = document.getElementById('file');
var preview = document.getElementById('preview');
var previewImg = document.getElementById('previewImg');
var previewName = document.getElementById('previewName');
var btn = document.getElementById('btn');
var status = document.getElementById('status');
var filesDiv = document.getElementById('files');
var selectedFile = null;
drop.addEventListener('click', function() { fileInput.click(); });
fileInput.addEventListener('change', function() {
if (fileInput.files && fileInput.files[0]) pick(fileInput.files[0]);
});
// Drag-and-drop (desktop fallback)
drop.addEventListener('dragover', function(e) { e.preventDefault(); drop.classList.add('active'); });
drop.addEventListener('dragleave', function() { drop.classList.remove('active'); });
drop.addEventListener('drop', function(e) {
e.preventDefault(); drop.classList.remove('active');
if (e.dataTransfer && e.dataTransfer.files[0]) pick(e.dataTransfer.files[0]);
});
function pick(f) {
selectedFile = f;
previewImg.src = URL.createObjectURL(f);
previewName.textContent = f.name + ' (' + (f.size / 1024).toFixed(0) + ' KB)';
preview.style.display = 'block';
btn.classList.add('ready');
status.className = 'status';
status.style.display = 'none';
}
btn.addEventListener('click', function() {
if (!selectedFile) return;
btn.classList.remove('ready');
btn.textContent = 'Uploading\u2026';
var form = new FormData();
form.append('file', selectedFile);
fetch('/api/screenshots', { method: 'POST', body: form })
.then(function(r) { return r.json(); })
.then(function(j) {
if (j.success) {
status.className = 'status ok';
status.textContent = 'Saved: ' + j.filename;
status.style.display = 'block';
selectedFile = null;
preview.style.display = 'none';
btn.textContent = 'Upload';
loadFiles();
} else {
status.className = 'status err';
status.textContent = j.error || 'Upload failed';
status.style.display = 'block';
btn.classList.add('ready');
btn.textContent = 'Upload';
}
})
.catch(function(e) {
status.className = 'status err';
status.textContent = e.message;
status.style.display = 'block';
btn.classList.add('ready');
btn.textContent = 'Upload';
});
});
function loadFiles() {
fetch('/api/screenshots')
.then(function(r) { return r.json(); })
.then(function(j) {
if (j.files && j.files.length) {
var html = '<h2>Recent uploads</h2>';
j.files.forEach(function(f) {
html += '<a href="/api/screenshots/' + encodeURIComponent(f.name) + '" target="_blank">' + f.name + '</a>';
});
filesDiv.innerHTML = html;
}
})
.catch(function() {});
}
loadFiles();
})();
</script>
</body></html>
+126
View File
@@ -134,6 +134,10 @@ const MAX_TERMINAL_ROWS = 200;
const MAX_SESSION_NAME_LENGTH = 128;
// Maximum hook data size (prevents oversized SSE broadcasts)
const MAX_HOOK_DATA_SIZE = 8 * 1024;
// Maximum screenshot upload size (10MB)
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
// Screenshots directory
const SCREENSHOTS_DIR = join(homedir(), '.claudeman', 'screenshots');
// Stats collection interval (2 seconds)
const STATS_COLLECTION_INTERVAL_MS = 2000;
// Session limit wait time before retrying (5 seconds)
@@ -519,6 +523,12 @@ export class WebServer extends EventEmitter {
}
private async setupRoutes(): Promise<void> {
// Allow multipart/form-data for screenshot uploads — skip Fastify's body parser
// so the route handler can read the raw stream directly.
this.app.addContentTypeParser('multipart/form-data', (_req, _payload, done) => {
done(null);
});
// Enable gzip/brotli compression for all responses.
// Massive win: 793KB uncompressed → ~120KB compressed for static assets.
// Threshold 1024 = don't compress tiny responses (headers > savings).
@@ -3384,6 +3394,122 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
return { success: true };
});
// Screenshot upload endpoint (accepts multipart/form-data)
// Upload form served as static file: /upload.html (src/web/public/upload.html)
this.app.post('/api/screenshots', async (req, reply) => {
const contentType = req.headers['content-type'] ?? '';
if (!contentType.includes('multipart/form-data')) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
}
// Parse multipart boundary
const boundaryMatch = contentType.match(/boundary=(.+?)(?:;|$)/);
if (!boundaryMatch) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing boundary');
}
// Collect raw body
const chunks: Buffer[] = [];
let totalSize = 0;
for await (const chunk of req.raw) {
totalSize += chunk.length;
if (totalSize > MAX_SCREENSHOT_SIZE) {
reply.status(413);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File too large (max 10MB)');
}
chunks.push(chunk as Buffer);
}
const body = Buffer.concat(chunks);
// Extract file from multipart body
const boundary = '--' + boundaryMatch[1];
const boundaryBuf = Buffer.from(boundary);
const parts: { headers: string; data: Buffer }[] = [];
let pos = 0;
// Find each part between boundaries
while (pos < body.length) {
const start = body.indexOf(boundaryBuf, pos);
if (start === -1) break;
const afterBoundary = start + boundaryBuf.length;
// Check for closing boundary (--)
if (body[afterBoundary] === 0x2d && body[afterBoundary + 1] === 0x2d) break;
// Skip \r\n after boundary
const headerStart = afterBoundary + 2;
const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'), headerStart);
if (headerEnd === -1) break;
const headers = body.subarray(headerStart, headerEnd).toString();
const dataStart = headerEnd + 4;
const nextBoundary = body.indexOf(boundaryBuf, dataStart);
// Data ends 2 bytes before next boundary (\r\n)
const dataEnd = nextBoundary === -1 ? body.length : nextBoundary - 2;
parts.push({ headers, data: body.subarray(dataStart, dataEnd) });
pos = nextBoundary === -1 ? body.length : nextBoundary;
}
const filePart = parts.find(p => p.headers.includes('name="file"'));
if (!filePart || filePart.data.length === 0) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No file uploaded');
}
// Determine extension from Content-Type or filename
let ext = '.png';
const filenameMatch = filePart.headers.match(/filename="(.+?)"/);
if (filenameMatch) {
const origExt = filenameMatch[1].match(/\.(png|jpg|jpeg|webp|gif)$/i);
if (origExt) ext = origExt[0].toLowerCase();
}
const ctMatch = filePart.headers.match(/Content-Type:\s*image\/(png|jpeg|webp|gif)/i);
if (ctMatch) {
const map: Record<string, string> = { png: '.png', jpeg: '.jpg', webp: '.webp', gif: '.gif' };
ext = map[ctMatch[1].toLowerCase()] ?? ext;
}
// Save to ~/.claudeman/screenshots/
if (!existsSync(SCREENSHOTS_DIR)) {
mkdirSync(SCREENSHOTS_DIR, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
const filename = `screenshot_${timestamp}${ext}`;
const filepath = join(SCREENSHOTS_DIR, filename);
writeFileSync(filepath, filePart.data);
return { success: true, path: filepath, filename };
});
// List screenshots
this.app.get('/api/screenshots', async () => {
if (!existsSync(SCREENSHOTS_DIR)) {
return { files: [] };
}
const files = readdirSync(SCREENSHOTS_DIR)
.filter(f => /\.(png|jpg|jpeg|webp|gif)$/i.test(f))
.sort()
.reverse()
.slice(0, 50)
.map(name => ({ name, path: join(SCREENSHOTS_DIR, name) }));
return { files };
});
// Serve individual screenshot
this.app.get('/api/screenshots/:name', async (req, reply) => {
const { name } = req.params as { name: string };
// Prevent path traversal
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
reply.status(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid filename');
}
const filepath = join(SCREENSHOTS_DIR, name);
if (!existsSync(filepath)) {
reply.status(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Screenshot not found');
}
const ext = name.match(/\.(png|jpg|jpeg|webp|gif)$/i)?.[1]?.toLowerCase() ?? 'png';
const mimeMap: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' };
reply.type(mimeMap[ext] ?? 'image/png');
return fs.readFile(filepath);
});
}
/**