fix: restore mobile terminal scrollback — use JS scrollLines() instead of broken native scroll

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) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-03-20 09:38:18 +01:00
co-authored by Claude Opus 4.6
parent 580b7a3f90
commit 497ca4891a
5 changed files with 49 additions and 68 deletions
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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();
+10 -31
View File
@@ -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
+36 -34
View File
@@ -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();
+1 -1
View File
@@ -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, ...