diff --git a/CLAUDE.md b/CLAUDE.md index 465461df..f25ed441 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1510 (must match `package.json` for npm publish) +**Version**: 0.1511 (must match `package.json` for npm publish) ## Project Overview @@ -221,6 +221,17 @@ npx vitest run -t "pattern" **Respawn tests**: Use MockSession from `test/respawn-test-utils.ts` to avoid spawning real Claude processes. +## Screenshots ("sc") + +When the user says "check the sc", "screenshot", or "sc", they mean uploaded screenshots from their mobile device. Screenshots are saved to `~/.claudeman/screenshots/` and uploaded via `/upload.html` on the Claudeman web UI. To view them, use the Read tool on the image files: + +```bash +ls ~/.claudeman/screenshots/ # List uploaded screenshots +# Then use Read tool on individual files — Claude Code can view images natively +``` + +API: `GET /api/screenshots` (list), `GET /api/screenshots/:name` (serve), `POST /api/screenshots` (upload multipart/form-data). Source: `src/web/public/upload.html`. + ## Debugging ```bash diff --git a/package.json b/package.json index 1a5dfaa0..326433de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1510", + "version": "0.1511", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/web/public/app.js b/src/web/public/app.js index 3c1a79d8..40a1cdb3 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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) */ diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css new file mode 100644 index 00000000..f16ff680 --- /dev/null +++ b/src/web/public/mobile.css @@ -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; + } +} diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 37b285c0..eec76f23 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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 { diff --git a/src/web/public/upload.html b/src/web/public/upload.html new file mode 100644 index 00000000..e9e39dc6 --- /dev/null +++ b/src/web/public/upload.html @@ -0,0 +1,155 @@ + + + + +Upload Screenshot - Claudeman + + +
+ ← Back to Claudeman +

Upload Screenshot

+
+

Tap to select image

+ PNG, JPG, WebP — up to 10 MB +
+ +
+ Preview +
+
+ +
+
+
+ + diff --git a/src/web/server.ts b/src/web/server.ts index bb353c22..fcb927a0 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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 { + // 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 = { 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 = { 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); + }); } /**