web: restore WS keepalive + reconnect pill; Esc closes the lightbox
The rewrite dropped the WebSocket keepalive and the connection-status UI, so idle proxies silently dropped the socket (esp. on mobile) with no cue. Restore: - a 25s keepalive ping while connected (server ignores unknown frames) - a floating 'Reconnecting…' pill shown only after a sustained outage (>2.5s), and cleared on reconnect; ws.onerror closes to trigger the reconnect path Also: pressing Esc now closes the image lightbox (was click-to-dismiss only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3 files changed,
+66
−2
web/pages/index.html+1 −0web/static/chat.js+49 −2web/static/style.css+16 −0
diff --git a/web/pages/index.html b/web/pages/index.html| index c79284a..37a408a 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -16,6 +16,7 @@ |
| On mobile the two panes stack and we toggle .show-thread to navigate. --> |
| <template id="app-tpl"> |
| <div class="tt" id="tt"> |
| + <div class="conn-status hidden" id="conn-status" role="status" aria-live="polite">Reconnecting…</div> |
| <aside class="sidebar"> |
| <header class="side-head"> |
| <button class="avatar-btn" id="me-btn" aria-label="Edit profile" title="Edit profile"> |
diff --git a/web/static/chat.js b/web/static/chat.js| index e1fec51..023253d 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -124,6 +124,8 @@ const threadPreview = {}; // thread id -> last-message snippet |
| const unread = {}; // thread id -> bool |
| let activeId = null; |
| let ws = null; |
| +let pingTimer = null; // keepalive ping interval while the socket is open |
| +let reconnectStatusTimer = null; // delays the "Reconnecting…" pill past a brief blip |
| route(); |
| @@ -1466,15 +1468,45 @@ function openLightbox(url) { |
| const img = document.createElement("img"); |
| img.src = url; |
| back.appendChild(img); |
| - back.onclick = () => back.remove(); |
| + const close = () => { |
| + back.remove(); |
| + document.removeEventListener("keydown", onKey); |
| + }; |
| + const onKey = (e) => { |
| + if (e.key === "Escape") close(); |
| + }; |
| + back.onclick = close; |
| + document.addEventListener("keydown", onKey); |
| document.body.appendChild(back); |
| } |
| // ---- websocket ---------------------------------------------------------- |
| +// Show (or hide, with "") the floating connection-status pill. |
| +function showConnStatus(text) { |
| + const el = document.getElementById("conn-status"); |
| + if (!el) return; |
| + el.textContent = text || "Reconnecting…"; |
| + el.classList.toggle("hidden", !text); |
| +} |
| + |
| function connectWS() { |
| const proto = location.protocol === "https:" ? "wss://" : "ws://"; |
| ws = new WebSocket(`${proto}${location.host}/ws?token=${encodeURIComponent(session.token)}`); |
| + ws.onopen = () => { |
| + // (Re)connected: hide the pill and (re)start the keepalive. |
| + if (reconnectStatusTimer) { |
| + clearTimeout(reconnectStatusTimer); |
| + reconnectStatusTimer = null; |
| + } |
| + showConnStatus(""); |
| + if (pingTimer) clearInterval(pingTimer); |
| + // Idle proxies drop WebSockets after ~60s; a tiny ping every 25s keeps it open. |
| + // The server ignores unknown frame types (see hub.go). |
| + pingTimer = setInterval(() => { |
| + if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" })); |
| + }, 25000); |
| + }; |
| ws.onmessage = async (ev) => { |
| let msg; |
| try { |
| @@ -1574,7 +1606,22 @@ function connectWS() { |
| } |
| renderThreadList(); |
| }; |
| - ws.onclose = () => setTimeout(connectWS, 2000); |
| + ws.onclose = () => { |
| + if (pingTimer) { |
| + clearInterval(pingTimer); |
| + pingTimer = null; |
| + } |
| + // Only flash "Reconnecting…" if the outage persists past a brief blip. |
| + if (!reconnectStatusTimer) { |
| + reconnectStatusTimer = setTimeout(() => showConnStatus("Reconnecting…"), 2500); |
| + } |
| + setTimeout(connectWS, 2000); |
| + }; |
| + ws.onerror = () => { |
| + try { |
| + ws.close(); |
| + } catch {} |
| + }; |
| } |
| function bumpThread(id) { |
diff --git a/web/static/style.css b/web/static/style.css| index b25b341..3efdfca 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -858,6 +858,22 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| /* ---- multi-thread layout (docs/threads-and-dms.md) ---- */ |
| .tt { display: flex; height: 100%; width: 100%; background: var(--panel); } |
| +/* floating connection-status pill (only shown during a sustained outage) */ |
| +.conn-status { |
| + position: fixed; |
| + top: calc(10px + env(safe-area-inset-top, 0px)); |
| + left: 50%; |
| + transform: translateX(-50%); |
| + z-index: 90; |
| + background: var(--ink); |
| + color: #fff; |
| + font-size: 13px; |
| + font-weight: 600; |
| + padding: 6px 14px; |
| + border-radius: 999px; |
| + box-shadow: 0 6px 20px rgba(20, 30, 80, 0.25); |
| +} |
| +.conn-status.hidden { display: none; } |
| .sidebar { width: 320px; flex: none; display: flex; flex-direction: column; border-right: 1px solid var(--border); min-height: 0; } |
| /* Both headers share a height so their bottom borders line up across the panes. */ |
| .side-head, .thread-head { box-sizing: border-box; min-height: 62px; padding: 8px 16px; border-bottom: 1px solid var(--border); } |