From cbfd159f8e8d28a16ca93c91bd7090e8e979206e Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Tue, 5 May 2026 01:11:59 -0700 Subject: [PATCH] feat(ws): pull-to-reconnect on touch devices (Fixes #1063) (#1068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Reframes the browser's native pull-to-refresh on touch devices as a **WebSocket reconnect** instead of a full page reload. On data pages (Packets, Nodes, Channels — and globally, since the WS is shared) a downward pull at `scrollTop=0` cycles the WS, which is what users actually want when they reach for that gesture. Fixes #1063. ## Behavior - **Touch-only**: gated by `('ontouchstart' in window) || navigator.maxTouchPoints > 0`. Desktop is untouched. - **Scroll-safe**: every handler re-checks `scrollTop > 0` and bails out — never hijacks normal scroll. - **Visual affordance**: a fixed chip slides down from the top with a rotating ⟳ icon; opacity and rotation scale with pull progress (0 → `PULL_THRESHOLD_PX = 80px`). - **`preventDefault` is conservative**: only after `dy > 16px` and only on `touchmove`, so taps and short swipes are not affected. - **Result feedback**: a brief toast — green `Connected ✓` if WS was already OPEN, `Reconnecting…` otherwise. Both auto-dismiss after ~1.8s. - **Reconnect path**: closes the existing WS so the existing `onclose` auto-reconnect fires immediately; an explicit `connectWS()` is also called as a safety net when `ws` is null. - **No regression** to existing WS auto-reconnect — same `connectWS` / `setTimeout(connectWS, 3000)` chain, just kicked manually. ## TDD - **Red commit** `f90f5e9` — adds `test-pull-to-reconnect.js` with 6 assertions; stub functions added to `app.js` so tests reach assertion failures (not ReferenceError). 3/6 fail on behavior. - **Green commit** `53adbd9` — full implementation; 6/6 pass. ## Files - `public/app.js` — `pullReconnect()`, `setupPullToReconnect()`, `_ensurePullIndicator()`, `_showPullToast()`, `_isTouchDevice()`. Wired into `DOMContentLoaded` next to `connectWS()`. Touched the WS section only. - `test-pull-to-reconnect.js` — vm sandbox suite covering exposure, WS-close, listener wiring, threshold trigger, scroll-position gate. ## Acceptance criteria check - ✅ Pull-down at scroll-top triggers WS reconnect + data refetch (debounced cache invalidate fires on next WS message) - ✅ Visible affordance during pull (rotating chip) - ✅ Resolves on success (toast), shows status toast on disconnect path - ✅ Disabled when not at `scrollTop=0` - ✅ No regression to existing WS auto-reconnect --------- Co-authored-by: openclaw-bot Co-authored-by: Kpa-clawbot --- public/app.js | 143 ++++++++++++++++++++++++ test-pull-to-reconnect.js | 225 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 test-pull-to-reconnect.js diff --git a/public/app.js b/public/app.js index e9091ea9..5ffaf7ef 100644 --- a/public/app.js +++ b/public/app.js @@ -501,6 +501,148 @@ function connectWS() { function onWS(fn) { wsListeners.push(fn); } function offWS(fn) { wsListeners = wsListeners.filter(f => f !== fn); } +// --- Pull-to-reconnect (#1063) --- +// Touch-device pull-down at scrollTop=0 reconnects the WebSocket +// (instead of triggering native pull-to-refresh full-page reload). +// Visual indicator pulses during pull; toast confirms result. +const PULL_THRESHOLD_PX = 80; +let _pullToast = null; +let _pullToastTimer = null; +let _pullIndicator = null; + +function _ensurePullIndicator() { + if (_pullIndicator && document.body && typeof document.body.contains === 'function' && document.body.contains(_pullIndicator)) return _pullIndicator; + if (_pullIndicator) return _pullIndicator; + const el = document.createElement('div'); + el.id = 'pullReconnectIndicator'; + el.setAttribute('aria-hidden', 'true'); + el.innerHTML = ''; + el.style.cssText = [ + 'position:fixed', 'top:0', 'left:50%', 'transform:translate(-50%,-100%)', + 'z-index:99999', 'padding:8px 14px', 'border-radius:0 0 12px 12px', + 'background:var(--accent,#2563eb)', 'color:#fff', 'font:14px/1 var(--font,system-ui)', + 'box-shadow:0 2px 8px rgba(0,0,0,.2)', 'pointer-events:none', + 'transition:transform .15s ease, opacity .15s ease', 'opacity:0', + ].join(';'); + document.body.appendChild(el); + _pullIndicator = el; + return el; +} + +function _showPullToast(msg, ok) { + try { + if (_pullToast && _pullToast.remove) _pullToast.remove(); + } catch (e) {} + if (_pullToastTimer) { try { clearTimeout(_pullToastTimer); } catch (e) {} _pullToastTimer = null; } + const el = document.createElement('div'); + el.className = 'pull-reconnect-toast'; + el.textContent = msg; + el.style.cssText = [ + 'position:fixed', 'top:12px', 'left:50%', 'transform:translateX(-50%)', + 'z-index:99999', 'padding:8px 16px', 'border-radius:8px', + 'background:' + (ok ? 'var(--status-green,#16a34a)' : 'var(--status-red,#dc2626)'), + 'color:#fff', 'font:14px/1.2 var(--font,system-ui)', + 'box-shadow:0 2px 8px rgba(0,0,0,.2)', 'pointer-events:none', + ].join(';'); + document.body.appendChild(el); + _pullToast = el; + _pullToastTimer = setTimeout(function () { + _pullToastTimer = null; + try { el.remove(); } catch (e) {} + }, 1800); +} + +function pullReconnect() { + // If WS is connected (readyState OPEN), give a brief "Connected ✓" + // confirmation but still cycle so the user sees fresh data. + const wasOpen = ws && ws.readyState === 1; + if (wasOpen) { + _showPullToast('Connected ✓', true); + // Fast cycle: close and let onclose reconnect immediately + try { ws.close(); } catch (e) {} + } else { + _showPullToast('Reconnecting…', true); + try { if (ws) ws.close(); } catch (e) {} + // onclose handler schedules reconnect; force one now in case ws was null + try { connectWS(); } catch (e) {} + } +} + +function _isTouchDevice() { + try { + return ('ontouchstart' in window) || + (navigator && (navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0)); + } catch (e) { return false; } +} + +function setupPullToReconnect() { + // Always attach listeners (tests + future-proof). Inside the handler we + // gate on _isTouchDevice() AND scrollTop=0 so desktop/scrolled pages are + // unaffected. + let startY = null; + let pulling = false; + let dist = 0; + + function getScrollTop() { + return (document.documentElement && document.documentElement.scrollTop) || + (document.body && document.body.scrollTop) || 0; + } + + function onStart(e) { + if (!_isTouchDevice()) return; + if (getScrollTop() > 0) { startY = null; pulling = false; return; } + const t = e.touches && e.touches[0]; + startY = t ? t.clientY : null; + pulling = false; + dist = 0; + } + + function onMove(e) { + if (startY == null) return; + if (getScrollTop() > 0) { startY = null; pulling = false; return; } + const t = e.touches && e.touches[0]; + if (!t) return; + const dy = t.clientY - startY; + if (dy <= 0) return; // upward swipe — ignore + dist = dy; + if (dy > 8) { + pulling = true; + const ind = _ensurePullIndicator(); + const pct = Math.min(1, dy / PULL_THRESHOLD_PX); + ind.style.opacity = String(pct); + ind.style.transform = 'translate(-50%, ' + (-100 + pct * 100) + '%)'; + const icon = ind.querySelector && ind.querySelector('.prr-icon'); + if (icon) icon.style.transform = 'rotate(' + Math.round(pct * 360) + 'deg)'; + // Prevent native pull-to-refresh ONLY once we've committed to the gesture + if (dy > 16 && typeof e.preventDefault === 'function' && e.cancelable !== false) { + try { e.preventDefault(); } catch (_) {} + } + } + } + + function onEnd() { + const wasPulling = pulling; + const finalDist = dist; + startY = null; pulling = false; dist = 0; + if (_pullIndicator) { + _pullIndicator.style.opacity = '0'; + _pullIndicator.style.transform = 'translate(-50%, -100%)'; + } + if (wasPulling && finalDist >= PULL_THRESHOLD_PX) { + try { (window.pullReconnect || pullReconnect)(); } catch (e) {} + } + } + + document.addEventListener('touchstart', onStart, { passive: true }); + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onEnd, { passive: true }); + document.addEventListener('touchcancel', onEnd, { passive: true }); +} + +window.pullReconnect = pullReconnect; +window.setupPullToReconnect = setupPullToReconnect; +window.connectWS = connectWS; + /* Global escapeHtml — used by multiple pages */ function escapeHtml(s) { if (s == null) return ''; @@ -676,6 +818,7 @@ window.addEventListener('timestamp-mode-changed', () => { }); window.addEventListener('DOMContentLoaded', () => { connectWS(); + setupPullToReconnect(); // --- Dark Mode --- const darkToggle = document.getElementById('darkModeToggle'); diff --git a/test-pull-to-reconnect.js b/test-pull-to-reconnect.js new file mode 100644 index 00000000..5f2ccdc3 --- /dev/null +++ b/test-pull-to-reconnect.js @@ -0,0 +1,225 @@ +/* test-pull-to-reconnect.js — behavioral tests for pull-to-reconnect (#1063) + * Loads app.js in a vm sandbox, stubs WebSocket + DOM, asserts that: + * - pullReconnect() exists as a global helper + * - calling it closes the existing WS (which triggers the existing + * auto-reconnect path) + * - setupPullToReconnect() exists and wires touchstart/touchmove/touchend + * listeners on the document + * - a pull-down gesture at scrollTop=0 over the threshold triggers + * pullReconnect + * - a touch when scrollTop > 0 does NOT trigger pullReconnect (don't + * hijack normal scrolling) + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +console.log('--- test-pull-to-reconnect.js ---'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(` ✅ ${name}`); } + catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}\n ${e.stack.split('\n').slice(1, 3).join('\n ')}`); } +} + +function makeSandbox(opts) { + opts = opts || {}; + const listeners = {}; // event name -> [fn] + const elements = {}; + function makeEl(id) { + const el = { + id, textContent: '', innerHTML: '', value: '', + style: {}, dataset: {}, + _classes: new Set(), + classList: { + add: function() { for (const a of arguments) el._classes.add(a); }, + remove: function() { for (const a of arguments) el._classes.delete(a); }, + toggle: function(c) { if (el._classes.has(c)) el._classes.delete(c); else el._classes.add(c); }, + contains: function(c) { return el._classes.has(c); }, + }, + addEventListener: function(ev, fn) { (el['_on_' + ev] = el['_on_' + ev] || []).push(fn); }, + removeEventListener: function() {}, + setAttribute: function() {}, getAttribute: function() { return null; }, + appendChild: function(child) { (el._children = el._children || []).push(child); return child; }, + remove: function() {}, + querySelector: function() { return null; }, + querySelectorAll: function() { return []; }, + }; + elements[id] = el; + return el; + } + + // Pre-create elements app.js touches at WS time + makeEl('liveDot'); + + // Stub WebSocket — track instances + close calls + const wsInstances = []; + function FakeWS(url) { + this.url = url; + this.readyState = 1; // OPEN + this.closed = false; + this.onopen = null; this.onclose = null; this.onerror = null; this.onmessage = null; + wsInstances.push(this); + // simulate immediate open so onopen fires synchronously isn't required; + // tests will invoke handlers directly when needed. + } + FakeWS.prototype.close = function() { + this.closed = true; + if (typeof this.onclose === 'function') this.onclose({}); + }; + FakeWS.prototype.send = function() {}; + + const body = makeEl('body'); + + const ctx = { + console, + setTimeout: function(fn, ms) { return 0; }, // suppress reconnect loop + clearTimeout: function() {}, + setInterval: function() { return 0; }, + clearInterval: function() {}, + Date, Math, JSON, Object, Array, String, Number, Boolean, + Error, RegExp, Map, Set, Symbol, Promise, + requestAnimationFrame: function(fn) { return 0; }, + performance: { now: function() { return 0; } }, + location: { protocol: 'http:', host: 'localhost', hash: '' }, + navigator: { userAgent: 'test' }, + WebSocket: FakeWS, + fetch: function() { return Promise.resolve({ ok: true, json: function() { return Promise.resolve({}); } }); }, + localStorage: { + _data: {}, + getItem: function(k) { return this._data[k] || null; }, + setItem: function(k, v) { this._data[k] = String(v); }, + removeItem: function(k) { delete this._data[k]; }, + }, + document: { + readyState: 'complete', + documentElement: { scrollTop: opts.scrollTop || 0, style: { setProperty: function() {} }, setAttribute: function() {}, getAttribute: function() { return null; } }, + body: body, + head: { appendChild: function() {} }, + createElement: function(tag) { return makeEl(tag); }, + getElementById: function(id) { return elements[id] || null; }, + querySelector: function() { return null; }, + querySelectorAll: function() { return []; }, + addEventListener: function(ev, fn) { (listeners[ev] = listeners[ev] || []).push(fn); }, + removeEventListener: function() {}, + dispatchEvent: function(e) { (listeners[e.type] || []).forEach(function(fn) { fn(e); }); return true; }, + }, + window: { + addEventListener: function() {}, removeEventListener: function() {}, dispatchEvent: function() {}, + matchMedia: function() { return { matches: false, addEventListener: function() {} }; }, + ontouchstart: opts.touch === false ? undefined : null, + }, + CustomEvent: function(type, init) { this.type = type; this.detail = (init || {}).detail; }, + }; + ctx.window.location = ctx.location; + ctx.window.localStorage = ctx.localStorage; + ctx.window.document = ctx.document; + ctx.self = ctx.window; + ctx.globalThis = ctx; + + vm.createContext(ctx); + return { ctx, elements, wsInstances, listeners }; +} + +function loadApp(box) { + const src = fs.readFileSync('public/app.js', 'utf8'); + vm.runInContext(src, box.ctx); +} + +console.log('\n=== pullReconnect helper exists ==='); +test('pullReconnect is exposed on window', () => { + const box = makeSandbox(); + loadApp(box); + assert.strictEqual(typeof box.ctx.window.pullReconnect, 'function', + 'window.pullReconnect must be a function'); +}); + +console.log('\n=== setupPullToReconnect exists ==='); +test('setupPullToReconnect is exposed on window', () => { + const box = makeSandbox(); + loadApp(box); + assert.strictEqual(typeof box.ctx.window.setupPullToReconnect, 'function', + 'window.setupPullToReconnect must be a function'); +}); + +console.log('\n=== pullReconnect closes existing WS ==='); +test('calling pullReconnect() closes the current WebSocket', () => { + const box = makeSandbox(); + loadApp(box); + // app.js does NOT call connectWS until DOMContentLoaded. Force one: + box.ctx.window.connectWS && box.ctx.window.connectWS(); + // If app.js doesn't expose connectWS, fall back to invoking pullReconnect + // and checking that something tries to open a new socket. + const beforeCount = box.wsInstances.length; + box.ctx.window.pullReconnect(); + // Either: existing WS got closed, OR a new WS was opened (reconnect) + const closed = box.wsInstances.some(function(w) { return w.closed; }); + const opened = box.wsInstances.length > beforeCount; + assert.ok(closed || opened, + 'pullReconnect must close the WS or open a new one (got closed=' + closed + ', opened=' + opened + ')'); +}); + +console.log('\n=== setupPullToReconnect wires document touch listeners ==='); +test('setupPullToReconnect attaches touchstart listener', () => { + const box = makeSandbox(); + loadApp(box); + box.ctx.window.setupPullToReconnect(); + assert.ok((box.listeners['touchstart'] || []).length > 0, + 'touchstart listener must be attached to document'); + assert.ok((box.listeners['touchmove'] || []).length > 0, + 'touchmove listener must be attached to document'); + assert.ok((box.listeners['touchend'] || []).length > 0, + 'touchend listener must be attached to document'); +}); + +console.log('\n=== Pull gesture at scrollTop=0 triggers reconnect ==='); +test('pull-down past threshold at scrollTop=0 triggers pullReconnect', () => { + const box = makeSandbox({ scrollTop: 0 }); + loadApp(box); + box.ctx.window.connectWS && box.ctx.window.connectWS(); + box.ctx.window.setupPullToReconnect(); + + let triggered = false; + const orig = box.ctx.window.pullReconnect; + box.ctx.window.pullReconnect = function() { triggered = true; return orig.apply(this, arguments); }; + + function fire(name, y) { + (box.listeners[name] || []).forEach(function(fn) { + fn({ touches: [{ clientY: y }], changedTouches: [{ clientY: y }], preventDefault: function() {}, type: name }); + }); + } + fire('touchstart', 10); + fire('touchmove', 100); + fire('touchmove', 200); + fire('touchend', 200); + + assert.ok(triggered, 'pullReconnect must be called after pull > threshold at scrollTop=0'); +}); + +console.log('\n=== Pull gesture when scrolled DOWN does NOT trigger ==='); +test('pull when scrollTop > 0 does NOT trigger pullReconnect', () => { + const box = makeSandbox({ scrollTop: 500 }); + loadApp(box); + box.ctx.window.connectWS && box.ctx.window.connectWS(); + box.ctx.window.setupPullToReconnect(); + + let triggered = false; + box.ctx.window.pullReconnect = function() { triggered = true; }; + + function fire(name, y) { + (box.listeners[name] || []).forEach(function(fn) { + fn({ touches: [{ clientY: y }], changedTouches: [{ clientY: y }], preventDefault: function() {}, type: name }); + }); + } + fire('touchstart', 10); + fire('touchmove', 200); + fire('touchend', 200); + + assert.strictEqual(triggered, false, + 'pullReconnect must NOT fire when page is scrolled (scrollTop > 0)'); +}); + +console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ===\n'); +process.exit(failed > 0 ? 1 : 0);