From 874873541d31ee05fdb6d127d0215e293d992cee Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 20:16:12 +0200 Subject: [PATCH] fix: xterm.js mobile touch scrolling via viewport CSS and stopPropagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add full mobile viewport CSS: overflow-y scroll, -webkit-overflow-scrolling touch, overscroll-behavior-y contain, translate3d hardware accel, scroll-behavior smooth, touch-action pan-y - After term.open(), find .xterm-viewport and add passive touch listeners that call stopPropagation() (not preventDefault) — this lets the browser handle native touch scrolling while preventing xterm.js internal handlers from interfering - Based on xterm.js known issue #5489 and SCROLLING_FIX.md approach --- apps/web/src/components/terminal.tsx | 34 ++++++++++++++++++++++++++-- apps/web/src/styles.css | 5 ++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index 117820d..980afa4 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -311,6 +311,37 @@ export const TerminalComponent = React.forwardRef( // Open xterm first (must happen before fit) term.open(container); term.focus(); + + // Mobile: allow native touch scroll on xterm viewport. + // xterm.js 5.x+ uses SmoothScrollableElement which can block native + // touch scrolling. We add passive touch listeners that call + // stopPropagation (not preventDefault) so the browser handles the + // gesture natively while xterm.js internal handlers don't fire. + let touchCleanup: (() => void) | undefined; + if (isMobile) { + const viewport = container.querySelector( + ".xterm-viewport", + ) as HTMLElement | null; + if (viewport) { + const onTouchStart = (e: TouchEvent) => { + e.stopPropagation(); + }; + const onTouchMove = (e: TouchEvent) => { + e.stopPropagation(); + }; + viewport.addEventListener("touchstart", onTouchStart, { + passive: true, + }); + viewport.addEventListener("touchmove", onTouchMove, { + passive: true, + }); + touchCleanup = () => { + viewport.removeEventListener("touchstart", onTouchStart); + viewport.removeEventListener("touchmove", onTouchMove); + }; + } + } + const ws = connectWebSocket(); // Initial fit after layout settles (terminal must be opened first) @@ -429,8 +460,6 @@ export const TerminalComponent = React.forwardRef( }; document.addEventListener("visibilitychange", handleVisibilityChange); - - return () => { isUnmountingRef.current = true; clearTimeout(resizeTimeout); @@ -442,6 +471,7 @@ export const TerminalComponent = React.forwardRef( "visibilitychange", handleVisibilityChange, ); + if (touchCleanup) touchCleanup(); if (ws) { ws.close(1000, "Component unmounting"); } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index a77c97a..9a0ec76 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -3716,6 +3716,11 @@ a.nav-item, /* Mobile: allow native vertical touch panning on xterm viewport */ .terminal-wrapper.mobile .xterm .xterm-viewport { touch-action: pan-y; + overflow-y: scroll !important; + -webkit-overflow-scrolling: touch !important; + overscroll-behavior-y: contain; + transform: translate3d(0, 0, 0); + scroll-behavior: smooth; } /* Special Keys Strip */