From bbebe23f7e3e259309974638cd01791c161d1109 Mon Sep 17 00:00:00 2001 From: happy5318 <34227488+happy5318@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:26:06 +0800 Subject: [PATCH] fix(mobile): preserve tablet hardware-keyboard Enter behavior --- README.md | 1 + static/boot.js | 24 ++--- tests/test_mobile_enter_newline.py | 140 ++++++++++++++++++++++++++++- tests/test_mobile_layout.py | 9 -- 4 files changed, 145 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 7e73b9f1..2cd05e65 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,7 @@ If an AI assistant is helping with install, reinstall, bootstrap, provider setup ### Settings and configuration - **Hermes Control Center** (sidebar launcher button) -- Conversation tab (export/import/clear), Preferences tab (model, send key, theme, language, all toggles), System tab (version, password) - Send key: Enter (default) or Ctrl/Cmd+Enter +- Send-key on touch devices: plain Enter inserts a newline on phones (iPhone/iPod, Android phones) and on tablets / iPadOS / touch-capable Macs that only expose a coarse pointer (no attached hardware keyboard), matching the software keyboard's return key. Devices that report a fine pointer (for example, a tablet with a hardware keyboard) keep the configured physical-keyboard send-key behavior. The configured shortcut and the Send button remain available for sending. - Show/hide CLI sessions toggle (enabled by default) - Token usage display toggle (off by default, also via `/usage` command) - Control Center always opens on the Conversation tab; resets on close diff --git a/static/boot.js b/static/boot.js index 1e2d4792..a79515a4 100644 --- a/static/boot.js +++ b/static/boot.js @@ -2388,26 +2388,16 @@ window._isImeEnter=_isImeEnter; function _hasFinePointerCoexisting(){ try{ return matchMedia('(any-pointer:fine)').matches; }catch(_){ return false; } } -// Detect mobile/touch-only devices. On some iOS Safari versions -// matchMedia('(pointer:coarse)') can be unreliable, so also check -// the user agent for mobile indicators as a fallback. +// Detect phone software keyboards without undoing #3076's hardware-input +// guard for tablets. Some iOS Safari versions report (any-pointer:fine) on a +// plain iPhone, so phone UAs bypass that unreliable signal. Tablets and +// touch-capable desktop UAs still require a coarse pointer with no fine pointer. function _isTouchOnlyDevice(){ - // Phone/tablet UA is authoritative and checked FIRST. Do NOT veto it with - // (any-pointer:fine): several iOS Safari builds report that query as true on - // plain iPhones (Apple Pencil / pointer-emulation heuristics), which is - // exactly why the media-query-only detection failed here. On a phone the - // software keyboard's return key must insert a newline; a user with a real - // Bluetooth keyboard still has Ctrl/Cmd+Enter and the send button. const ua=navigator.userAgent||''; - if(/iPhone|iPod|Android/i.test(ua)) return true; - // iPadOS 13+ Safari masquerades as Macintosh; touch points disambiguate it. - if(/iPad/i.test(ua)) return true; + if(/iPhone|iPod/i.test(ua)) return true; + if(/Android.*Mobile/i.test(ua)) return true; try{ - if(/Macintosh/i.test(ua)&&(navigator.maxTouchPoints||0)>1) return true; - }catch(_){} - // Non-phone fallback: pure media-query detection for touch-primary devices. - try{ - return matchMedia('(pointer:coarse)').matches&&!matchMedia('(any-pointer:fine)').matches; + return matchMedia('(pointer:coarse)').matches&&!_hasFinePointerCoexisting(); }catch(_){} return false; } diff --git a/tests/test_mobile_enter_newline.py b/tests/test_mobile_enter_newline.py index 9fd030ee..1d6f5afd 100644 --- a/tests/test_mobile_enter_newline.py +++ b/tests/test_mobile_enter_newline.py @@ -3,13 +3,14 @@ This module covers the three production changes in this PR, each with a test that provably fails when only that change is reverted: -A. ``_isTouchOnlyDevice()`` replaces media-query-only touch detection. +A. ``_isTouchOnlyDevice()`` supplements media-query touch detection for phones. Several iOS Safari builds report ``(any-pointer:fine)`` as **true** on a plain iPhone (Apple Pencil / pointer-emulation heuristics), so the upstream ``matchMedia('(pointer:coarse)') && !_hasFinePointerCoexisting()`` test evaluates false on exactly the devices it is meant to detect, and plain - Enter sends instead of inserting a newline. ``_isTouchOnlyDevice()`` checks - the phone/tablet UA **first** and never lets a fine-pointer signal veto it. + Enter sends instead of inserting a newline. Phone UAs bypass that unreliable + signal, while tablets still honor it so attached hardware keyboards retain + desktop Enter-to-send behavior. B. ``window._sendKey`` is initialised synchronously from ``localStorage`` before the composer handler is registered. Without it, on a slow network the @@ -44,6 +45,18 @@ IPHONE_UA = ( "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) " "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1" ) +IPAD_UA = ( + "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1" +) +ANDROID_TABLET_UA = ( + "Mozilla/5.0 (Linux; Android 14; SM-X910) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" +) +TOUCH_MAC_UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15" +) def _require_playwright(): @@ -197,6 +210,21 @@ DESKTOP_MATCHMEDIA = """ })(); """ +# A tablet / iPadOS-desktop UA with only a software keyboard: a coarse pointer +# is present (touch screen) but no fine pointer (no attached hardware +# keyboard or trackpad). This is the path that the production change leaves +# alone — these devices must keep the mobile "Enter inserts newline" behavior. +TABLET_NO_FINE_POINTER_MATCHMEDIA = """ +(() => { + const real = window.matchMedia.bind(window); + window.matchMedia = (q) => { + if (q === '(pointer:coarse)') return {matches: true, media: q}; + if (q === '(any-pointer:fine)') return {matches: false, media: q}; + return real(q); + }; +})(); +""" + def _press_enter(page, *, shift=False, ctrl=False, meta=False, numpad=False): page.evaluate( @@ -291,6 +319,112 @@ def test_android_phone_treats_enter_as_newline(): assert sends == 0, "plain Enter must insert a newline on an Android phone" +def _run_enter_case( + playwright, + harness: Path, + *, + user_agent: str, + max_touch_points: int = 0, + matchmedia_shim: str = IOS_SAFARI_MATCHMEDIA_QUIRK, + expect_coarse: bool = True, + expect_fine: bool = True, +) -> tuple[int, int]: + with playwright() as p: + browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"]) + ctx = browser.new_context( + user_agent=user_agent, + viewport={"width": 1024, "height": 768}, + has_touch=True, + ) + ctx.add_init_script( + f"Object.defineProperty(navigator, 'maxTouchPoints', {{value: {max_touch_points}}});" + ) + ctx.add_init_script(matchmedia_shim) + page = ctx.new_page() + page.goto(harness.as_uri()) + page.wait_for_load_state("domcontentloaded") + # Sanity: the shim is actually in place, otherwise the test would be vacuous. + assert page.evaluate("matchMedia('(pointer:coarse)').matches") is expect_coarse + assert page.evaluate("matchMedia('(any-pointer:fine)').matches") is expect_fine + _press_enter(page) + sends = _send_count(page) + prevented = page.evaluate("__defaultPrevented") + browser.close() + return sends, prevented + + +@pytest.mark.parametrize( + ("user_agent", "max_touch_points", "device"), + [ + (IPAD_UA, 5, "iPad with Magic Keyboard"), + (ANDROID_TABLET_UA, 5, "Android tablet with hardware keyboard"), + (TOUCH_MAC_UA, 5, "touch Macintosh/iPadOS desktop UA"), + ], +) +def test_tablet_with_fine_pointer_keeps_enter_to_send( + user_agent: str, + max_touch_points: int, + device: str, +): + """Tablet/touch-desktop UA + fine pointer must retain desktop Enter=send.""" + playwright = _require_playwright() + harness = _write_harness() + sends, prevented = _run_enter_case( + playwright, + harness, + user_agent=user_agent, + max_touch_points=max_touch_points, + ) + + assert sends == 1, f"plain Enter must send on {device}" + assert prevented == 1, f"plain Enter must be consumed after sending on {device}" + + +@pytest.mark.parametrize( + ("user_agent", "max_touch_points", "device"), + [ + (IPAD_UA, 5, "iPad with software keyboard only"), + (ANDROID_TABLET_UA, 5, "Android tablet with software keyboard only"), + (TOUCH_MAC_UA, 5, "touch Macintosh/iPadOS desktop with software keyboard only"), + ], +) +def test_tablet_without_fine_pointer_inserts_newline( + user_agent: str, + max_touch_points: int, + device: str, +): + """Control for the tablet-with-fine-pointer test. + + A tablet / iPadOS-desktop UA reporting only a coarse pointer (no fine + pointer — i.e. no attached hardware keyboard) must keep the mobile + "plain Enter inserts a newline" behavior, because the production change + only short-circuits phone UAs and otherwise still falls back to the + media-query test ``(pointer:coarse) && !(any-pointer:fine)``. + + Reverting the change to a phone-only match would break this test, and a + tablet shortcut that always returns true would invert + ``test_tablet_with_fine_pointer_keeps_enter_to_send``. + """ + playwright = _require_playwright() + harness = _write_harness() + sends, prevented = _run_enter_case( + playwright, + harness, + user_agent=user_agent, + max_touch_points=max_touch_points, + matchmedia_shim=TABLET_NO_FINE_POINTER_MATCHMEDIA, + expect_fine=False, + ) + + assert sends == 0, ( + f"plain Enter must insert a newline on {device} (no fine pointer " + "is exposed, so the media-query path should still classify it as touch-only)" + ) + assert prevented == 0, ( + f"Enter must fall through so the browser inserts the newline on {device}" + ) + + def test_mobile_ctrl_enter_still_sends(): """Change A must not cost mobile users a way to send from the keyboard.""" playwright = _require_playwright() diff --git a/tests/test_mobile_layout.py b/tests/test_mobile_layout.py index d8014810..b3746346 100644 --- a/tests/test_mobile_layout.py +++ b/tests/test_mobile_layout.py @@ -1697,15 +1697,6 @@ def test_mobile_enter_newline_does_not_depend_on_viewport_heuristic(): "the viewport height-delta probe must no longer gate the mobile Enter override" -def test_mobile_enter_newline_respects_hardware_keyboard_on_touch_devices(): - """Touch devices with a co-existing fine pointer (hardware keyboard) keep desktop Enter=send.""" - boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8") - assert "any-pointer:fine" in boot_js, \ - "boot.js must use any-pointer:fine to detect a co-existing hardware keyboard/trackpad" - assert "!_hasFinePointerCoexisting()" in boot_js, \ - "mobile Enter newline override must skip touch devices that also expose a fine pointer" - - def test_mobile_enter_newline_only_overrides_enter_default(): """Mobile newline override must only apply when _sendKey is the default 'enter'.""" boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")