From f54a4e8481c95f69e858d7c1af445c7860cfecde Mon Sep 17 00:00:00 2001 From: Edwin Date: Sat, 30 May 2026 17:38:53 -0700 Subject: [PATCH 1/4] Web UI: pull down on the header bar to refresh on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app shell is locked to `html, body { overflow: hidden }` so the browser's native swipe-down-to-refresh never engages. On a narrow phone viewport the URL-bar reload button is also hidden, which leaves no obvious way to refresh the page — and hijacking page-wide swipes would fight the session-view scroll. Scope the gesture to the `
` only: a vertical drag that starts on the title bar grows a sticky banner from the top of the viewport; releasing past a 70 px threshold reloads. Anything starting outside the header (chat scroll, session list, terminal) keeps its existing behavior. The handler differentiates from a tap by waiting until the drag has moved more than 8 px down before it engages, so hamburger / title / connection-indicator taps still fire normally. `touch-action: pan-y` on the header lets the browser dispatch touchmove cleanly, and we `preventDefault` only once tracking starts. --- crates/daemon/assets/index.html | 152 ++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index 4872d881..d1f93e77 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -88,6 +88,47 @@ background: linear-gradient(90deg, var(--modeline-bg, #082e18), #04140a 70%); border-bottom: 1px solid var(--border); box-shadow: inset 0 -1px 0 rgba(57, 255, 136, 0.16), var(--glow-soft); + /* Lets our pull-to-refresh handler track a vertical drag without the + browser fighting it. Horizontal scroll on the header isn't useful. */ + touch-action: pan-y; + } + /* Pull-to-refresh affordance. The page is locked at `overflow: hidden` so + the browser's native swipe-to-refresh never engages on mobile and the + URL bar refresh button is hidden on narrow screens. Custom handler + (see `initPullToRefresh`) tracks a downward drag that *starts on the + header* and grows this overlay; on release past the threshold the page + reloads. Starting the drag on the session view instead means "scroll + up", so we deliberately scope to the header only. */ + .pull-indicator { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 0; + z-index: 100; + pointer-events: none; + display: flex; + align-items: flex-end; + justify-content: center; + overflow: hidden; + background: linear-gradient(180deg, + color-mix(in srgb, var(--accent) 28%, transparent), transparent); + color: var(--accent); + font-size: 12px; + letter-spacing: 0.04em; + /* Snaps back when released under threshold. During the drag we add + `.dragging` to suppress the transition so the indicator tracks the + finger 1:1. */ + transition: height 0.18s ease-out, background 0.18s ease-out; + } + .pull-indicator.dragging { transition: none; } + .pull-indicator.armed { + background: linear-gradient(180deg, + color-mix(in srgb, var(--accent) 55%, transparent), transparent); + } + .pull-indicator .label { + padding-bottom: 6px; + font-weight: bold; } .mini-matrix { flex: 0 0 auto; @@ -5309,9 +5350,120 @@ syncBodyHeightToVisualViewport(); } +/** + * Pull-to-refresh on the header. + * + * The app shell is locked to `html, body { overflow: hidden }` so the + * browser's native swipe-down-to-refresh never engages, and on a narrow + * mobile viewport the URL-bar reload button is hidden, leaving no obvious + * way to refresh. Hijacking page-wide swipes would fight the session + * scroll, so this hooks just the
bar: a vertical drag that + * starts on the header grows a banner from the top; releasing past + * `THRESHOLD` reloads the page. Anything starting outside the header + * (chat scroll, list scroll, terminal) keeps its existing behavior. + * + * Differentiation from a tap: we only start tracking once the drag has + * moved more than `SLOP` pixels down, so taps on the hamburger, title, + * or connection indicator still fire normally. + */ +function initPullToRefresh() { + const header = document.querySelector("header"); + if (!header) return; + const SLOP = 8; + const THRESHOLD = 70; + const MAX_HEIGHT = 120; + const indicator = document.createElement("div"); + indicator.className = "pull-indicator"; + const label = document.createElement("div"); + label.className = "label"; + indicator.appendChild(label); + document.body.appendChild(indicator); + + let startY = null; + let dragging = false; + let armed = false; + + function setLabel(text) { + if (label.textContent !== text) label.textContent = text; + } + function reset() { + startY = null; + dragging = false; + armed = false; + indicator.classList.remove("dragging", "armed"); + indicator.style.height = "0"; + } + + header.addEventListener("touchstart", (e) => { + if (e.touches.length !== 1) { + reset(); + return; + } + startY = e.touches[0].clientY; + dragging = false; + armed = false; + }, { passive: true }); + + header.addEventListener("touchmove", (e) => { + if (startY === null || e.touches.length !== 1) return; + const dy = e.touches[0].clientY - startY; + if (dy < SLOP) { + // Either an upward drag (uninteresting) or still within tap slop. + if (dragging) reset(); + return; + } + if (!dragging) { + dragging = true; + indicator.classList.add("dragging"); + } + // The header's `touch-action: pan-y` would otherwise let the browser + // scroll, but body is `overflow: hidden` so there's nothing to scroll; + // preventDefault keeps the document from triggering pull-to-refresh + // chrome and lets us own the gesture cleanly. + e.preventDefault(); + const height = Math.min(dy - SLOP, MAX_HEIGHT); + indicator.style.height = `${height}px`; + if (dy >= THRESHOLD) { + if (!armed) { + armed = true; + indicator.classList.add("armed"); + } + setLabel("↑ release to refresh"); + } else { + if (armed) { + armed = false; + indicator.classList.remove("armed"); + } + setLabel("↓ pull down to refresh"); + } + }, { passive: false }); + + header.addEventListener("touchend", () => { + if (!dragging) { + reset(); + return; + } + if (armed) { + // Brief visual confirm so the user sees the gesture register before + // the page goes white during reload. + indicator.classList.remove("dragging"); + indicator.classList.add("armed"); + indicator.style.height = `${MAX_HEIGHT}px`; + setLabel("↻ refreshing…"); + setTimeout(() => location.reload(), 200); + return; + } + indicator.classList.remove("dragging"); + reset(); + }, { passive: true }); + + header.addEventListener("touchcancel", reset, { passive: true }); +} + // --- Boot --------------------------------------------------------------- initMiniMatrix(); +initPullToRefresh(); connect(); From 13078ec012c49b9fc5735062cb57590157a1162b Mon Sep 17 00:00:00 2001 From: Edwin Date: Sat, 30 May 2026 17:43:43 -0700 Subject: [PATCH 2/4] Soft-refresh on pull instead of location.reload() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `location.reload()` is the wrong UX for a pull-to-refresh gesture on mobile: it nukes the xterm scrollback, transcript scroll position, widgets, and any in-flight PTY input — and flashes the page white for half a second on a slow phone. Replace it with `softRefresh()`: close the active WebSocket and let the existing close handler -> scheduleReconnect -> connect path re-run end to end. That re-subscribes globally, re-fetches the session list, and hydrates the active session via the reconnect path (`refreshCurrentSessionAfterReconnect`), which already preserves the xterm buffer for terminal sessions. The reconnect-delay backoff is short-circuited to 50 ms so the gesture feels instant. --- crates/daemon/assets/index.html | 36 ++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index d1f93e77..c1008efe 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -2166,6 +2166,25 @@ state.reconnectDelay = Math.min(state.reconnectDelay * 2, 15000); } +/** + * User-initiated refresh: close the current socket so the existing reconnect + * machinery (close handler -> scheduleReconnect -> connect -> subscribe + + * refreshSessions + refreshCurrentSessionAfterReconnect) re-runs end to end. + * No `location.reload()` — that would clear xterm buffers, scroll position, + * widgets, and ongoing PTY input on mobile, which is the wrong UX for a + * pull-to-refresh gesture. The reconnect-delay backoff is short-circuited + * here so the user sees an immediate "connecting…" → "connected" flash + * rather than waiting on the current backoff window. + */ +function softRefresh() { + state.reconnectDelay = 50; + if (state.ws) { + try { state.ws.close(); } catch (_) { /* already-closed sockets are fine */ } + } else { + connect(); + } +} + function rpc(method, params) { return new Promise((resolve, reject) => { if (!state.ws || state.ws.readyState !== 1) { @@ -5444,13 +5463,24 @@ return; } if (armed) { - // Brief visual confirm so the user sees the gesture register before - // the page goes white during reload. + // Brief visual confirm before the soft refresh kicks in. We don't + // call `location.reload()` here — see `softRefresh()`. Instead we + // tear down the WebSocket; the close handler re-runs the same path + // a network hiccup would, which re-fetches the session list and + // re-hydrates the active session without losing xterm buffers, + // scroll position, or widgets. indicator.classList.remove("dragging"); indicator.classList.add("armed"); indicator.style.height = `${MAX_HEIGHT}px`; setLabel("↻ refreshing…"); - setTimeout(() => location.reload(), 200); + setTimeout(() => { + softRefresh(); + // Snap the indicator out of the way; the conn pill in the header + // already shows "connecting…" → "connected" so the user gets + // ongoing feedback after the banner disappears. + indicator.style.height = "0"; + setTimeout(reset, 220); + }, 200); return; } indicator.classList.remove("dragging"); From 1dfef290dcd04f73d28b37f99ffd13c5555488e2 Mon Sep 17 00:00:00 2001 From: Edwin Date: Sat, 30 May 2026 17:46:21 -0700 Subject: [PATCH 3/4] Rename softRefresh to hotReload to match user terminology --- crates/daemon/assets/index.html | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index c1008efe..dda5fd61 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -2167,16 +2167,17 @@ } /** - * User-initiated refresh: close the current socket so the existing reconnect - * machinery (close handler -> scheduleReconnect -> connect -> subscribe + - * refreshSessions + refreshCurrentSessionAfterReconnect) re-runs end to end. - * No `location.reload()` — that would clear xterm buffers, scroll position, - * widgets, and ongoing PTY input on mobile, which is the wrong UX for a - * pull-to-refresh gesture. The reconnect-delay backoff is short-circuited - * here so the user sees an immediate "connecting…" → "connected" flash - * rather than waiting on the current backoff window. + * User-initiated hot reload: close the current socket so the existing + * reconnect machinery (close handler -> scheduleReconnect -> connect -> + * subscribe + refreshSessions + refreshCurrentSessionAfterReconnect) + * re-runs end to end. Distinct from `location.reload()`, which would clear + * the xterm buffer, scroll position, widgets, and in-flight PTY input — + * the wrong UX for a pull-to-refresh gesture. The reconnect-delay backoff + * is short-circuited here so the user sees an immediate + * "connecting…" → "connected" flash rather than waiting on the current + * backoff window. */ -function softRefresh() { +function hotReload() { state.reconnectDelay = 50; if (state.ws) { try { state.ws.close(); } catch (_) { /* already-closed sockets are fine */ } @@ -5463,8 +5464,8 @@ return; } if (armed) { - // Brief visual confirm before the soft refresh kicks in. We don't - // call `location.reload()` here — see `softRefresh()`. Instead we + // Brief visual confirm before the hot reload kicks in. We don't + // call `location.reload()` here — see `hotReload()`. Instead we // tear down the WebSocket; the close handler re-runs the same path // a network hiccup would, which re-fetches the session list and // re-hydrates the active session without losing xterm buffers, @@ -5474,7 +5475,7 @@ indicator.style.height = `${MAX_HEIGHT}px`; setLabel("↻ refreshing…"); setTimeout(() => { - softRefresh(); + hotReload(); // Snap the indicator out of the way; the conn pill in the header // already shows "connecting…" → "connected" so the user gets // ongoing feedback after the banner disappears. From c8cb387d65df03f900dd5eb0e7a6b00ada8479e0 Mon Sep 17 00:00:00 2001 From: Edwin Date: Sat, 30 May 2026 17:55:35 -0700 Subject: [PATCH 4/4] Revert: pull-to-refresh calls location.reload() again You asked for the gesture to do a real refresh. The intervening soft-refresh detour was based on misreading 'can you hot reload' as a request to change the gesture behavior; you actually meant 'use agentd's webui_hot_reload tool to iterate'. Drop the helper function (its name was also clashing with the project's existing 'hot reload' terminology for the dev asset poller) and inline the original location.reload() back at the call site. --- crates/daemon/assets/index.html | 37 +++------------------------------ 1 file changed, 3 insertions(+), 34 deletions(-) diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index dda5fd61..d1f93e77 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -2166,26 +2166,6 @@ state.reconnectDelay = Math.min(state.reconnectDelay * 2, 15000); } -/** - * User-initiated hot reload: close the current socket so the existing - * reconnect machinery (close handler -> scheduleReconnect -> connect -> - * subscribe + refreshSessions + refreshCurrentSessionAfterReconnect) - * re-runs end to end. Distinct from `location.reload()`, which would clear - * the xterm buffer, scroll position, widgets, and in-flight PTY input — - * the wrong UX for a pull-to-refresh gesture. The reconnect-delay backoff - * is short-circuited here so the user sees an immediate - * "connecting…" → "connected" flash rather than waiting on the current - * backoff window. - */ -function hotReload() { - state.reconnectDelay = 50; - if (state.ws) { - try { state.ws.close(); } catch (_) { /* already-closed sockets are fine */ } - } else { - connect(); - } -} - function rpc(method, params) { return new Promise((resolve, reject) => { if (!state.ws || state.ws.readyState !== 1) { @@ -5464,24 +5444,13 @@ return; } if (armed) { - // Brief visual confirm before the hot reload kicks in. We don't - // call `location.reload()` here — see `hotReload()`. Instead we - // tear down the WebSocket; the close handler re-runs the same path - // a network hiccup would, which re-fetches the session list and - // re-hydrates the active session without losing xterm buffers, - // scroll position, or widgets. + // Brief visual confirm so the user sees the gesture register before + // the page goes white during reload. indicator.classList.remove("dragging"); indicator.classList.add("armed"); indicator.style.height = `${MAX_HEIGHT}px`; setLabel("↻ refreshing…"); - setTimeout(() => { - hotReload(); - // Snap the indicator out of the way; the conn pill in the header - // already shows "connecting…" → "connected" so the user gets - // ongoing feedback after the banner disappears. - indicator.style.height = "0"; - setTimeout(reset, 220); - }, 200); + setTimeout(() => location.reload(), 200); return; } indicator.classList.remove("dragging");