From 497ca4891aa09db75ddc7aa446bff968214044bf Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 20 Mar 2026 09:38:18 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20restore=20mobile=20terminal=20scrollback?= =?UTF-8?q?=20=E2=80=94=20use=20JS=20scrollLines()=20instead=20of=20broken?= =?UTF-8?q?=20native=20scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xterm.js DOM renderer doesn't populate .xterm-viewport's scroll area (the div is empty, scrollHeight === clientHeight), so native CSS scrolling via touch-action:pan-y and overflow-y:scroll had nothing to scroll. Desktop worked only because the wheel handler called terminal.scrollLines() directly. - Replace split mobile/desktop touch handlers with unified JS-driven handler that converts touch deltas to terminal.scrollLines() calls (with pixel accumulation for slow swipes and momentum scrolling) - Change touch-action from pan-y to none on terminal elements so browser doesn't fight the JS handler - Remove now-unnecessary xterm-viewport position/overflow/z-index overrides and iOS -webkit-overflow-scrolling rules - Fix _shrinkPaddingToFit() arithmetic (was adding gap instead of subtracting) - Minor: add route-helpers.ts to CLAUDE.md, fix sse-events.ts comment count Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- src/web/public/mobile-handlers.js | 2 +- src/web/public/mobile.css | 41 +++++------------- src/web/public/terminal-ui.js | 70 ++++++++++++++++--------------- src/web/sse-events.ts | 2 +- 5 files changed, 49 insertions(+), 68 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1dd4c79e..0ed1f408 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | | | **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | | -| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (13 route modules incl. `ws-routes.ts` + barrel), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | | +| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (13 route modules incl. `ws-routes.ts` + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | | | **Frontend** | `src/web/public/app.js` (~2.6K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 6 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | | | **Types** | `src/types/index.ts` → 13 domain files | See `@fileoverview` in index.ts | diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index 3e0c7950..0d9cd04b 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -446,7 +446,7 @@ const KeyboardHandler = { const gap = container.clientHeight - app.terminal.rows * cellH; if (gap > 0 && gap < cellH) { const currentPadding = parseInt(main.style.paddingBottom) || 0; - main.style.paddingBottom = currentPadding + gap + 'px'; + main.style.paddingBottom = Math.max(0, currentPadding - gap) + 'px'; if (app.fitAddon) try { app.fitAddon.fit(); diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index a1a61658..b9ddf2e4 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1128,36 +1128,21 @@ html.mobile-init .file-browser-panel { display: block; } - /* Mobile terminal - native touch scrolling via xterm-viewport */ + /* Mobile terminal — JS touch handler (terminal.scrollLines()) drives + scrollback because xterm.js DOM renderer doesn't populate xterm-viewport's + scroll area. touch-action:none lets our JS handler own the gesture. */ .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; + overflow: visible; + touch-action: none; } + .terminal-container .xterm, + .terminal-container .xterm-viewport, .terminal-container .xterm-screen { - touch-action: pan-y; + touch-action: none; } /* Compact welcome overlay for mobile */ @@ -2061,14 +2046,8 @@ html.mobile-init .file-browser-panel { 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; -} +/* JS touch handler now drives terminal scrollback — no native scroll needed. + -webkit-overflow-scrolling and overflow-y:scroll on xterm-viewport removed. */ /* ============================================================================ Hover State Fallbacks for Touch diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index cb29c3e7..28984cff 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -139,80 +139,82 @@ Object.assign(CodemanApp.prototype, { this.terminal.scrollLines(lines); }, { passive: false }); - // Touch scrolling - only use custom JS scrolling on desktop - // Mobile uses native browser scrolling via CSS touch-action: pan-y - const isMobileDevice = MobileDetection.isTouchDevice() && window.innerWidth < 1024; - - if (!isMobileDevice) { - // Desktop touch scrolling with custom momentum + // Touch scrolling — use terminal.scrollLines() for all devices. + // xterm.js DOM renderer doesn't populate xterm-viewport's scroll area, + // so native CSS scrolling (overflow-y: scroll + touch-action: pan-y) + // has nothing to scroll. Instead, convert touch deltas into scrollLines() + // calls, matching the wheel handler above. + { + const cellHeight = () => + this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13; let touchLastY = 0; - let pendingDelta = 0; let velocity = 0; let lastTime = 0; let scrollFrame = null; let isTouching = false; - const viewport = container.querySelector('.xterm-viewport'); - - // Single RAF loop handles both touch and momentum const scrollLoop = (timestamp) => { - if (!viewport) return; - - const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1; // Normalize to 60fps + const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1; lastTime = timestamp; - if (isTouching) { - // During touch: apply pending delta - if (pendingDelta !== 0) { - viewport.scrollTop += pendingDelta; - pendingDelta = 0; - } + if (!isTouching && Math.abs(velocity) > 0.3) { + // Momentum phase — convert pixel velocity to lines + const lines = Math.round(velocity / cellHeight()); + if (lines !== 0) this.terminal.scrollLines(lines); + velocity *= 0.92; scrollFrame = requestAnimationFrame(scrollLoop); - } else if (Math.abs(velocity) > 0.1) { - // Momentum phase - viewport.scrollTop += velocity * dt; - velocity *= 0.94; // Smooth deceleration - scrollFrame = requestAnimationFrame(scrollLoop); - } else { + } else if (!isTouching) { scrollFrame = null; velocity = 0; + } else { + scrollFrame = requestAnimationFrame(scrollLoop); } }; + // Accumulate sub-line pixel deltas so slow swipes still scroll + let pixelAccum = 0; + container.addEventListener('touchstart', (ev) => { if (ev.touches.length === 1) { touchLastY = ev.touches[0].clientY; - pendingDelta = 0; velocity = 0; + pixelAccum = 0; isTouching = true; lastTime = 0; - if (!scrollFrame) { - scrollFrame = requestAnimationFrame(scrollLoop); - } + if (scrollFrame) { cancelAnimationFrame(scrollFrame); scrollFrame = null; } } }, { passive: true }); container.addEventListener('touchmove', (ev) => { if (ev.touches.length === 1 && isTouching) { const touchY = ev.touches[0].clientY; - const delta = touchLastY - touchY; - pendingDelta += delta; - velocity = delta * 1.2; // Track for momentum + const delta = touchLastY - touchY; // positive = scroll down + pixelAccum += delta; + velocity = delta * 1.2; touchLastY = touchY; + // Convert accumulated pixels to whole lines + const ch = cellHeight(); + const lines = Math.trunc(pixelAccum / ch); + if (lines !== 0) { + this.terminal.scrollLines(lines); + pixelAccum -= lines * ch; + } } }, { passive: true }); container.addEventListener('touchend', () => { isTouching = false; - // Momentum continues in scrollLoop + if (!scrollFrame && Math.abs(velocity) > 0.3) { + scrollFrame = requestAnimationFrame(scrollLoop); + } }, { passive: true }); container.addEventListener('touchcancel', () => { isTouching = false; velocity = 0; + pixelAccum = 0; }, { passive: true }); } - // Mobile: native scrolling handles touch via CSS // Welcome message this.showWelcome(); diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index f8990d66..b72900d1 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -5,7 +5,7 @@ * and referenced by the frontend (`SSE_EVENTS` in `constants.js`). * Both files MUST be kept in sync. * - * ~90 event constants organized by category: + * ~106 event constants organized by category: * - **Core** (1): init * - **Session lifecycle** (17): created, updated, deleted, terminal, idle, working, ... * - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...