web: float the connection status pill + WebSocket keepalive (stop scroll jumps)
- the #banner ('Reconnecting…') was in normal flow between header and feed, so showing/hiding it resized the feed and jumped the scroll. It's now an absolutely-positioned pill over the top of the feed (wrapped in .feed-wrap), so it never reflows the message list.
- add a 25s app-level keepalive ping so idle proxies stop dropping the socket — 'Reconnecting…' now rarely appears at all. The server ignores unknown message types.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3 files changed,
+47
−2
web/pages/index.html+4 −2web/static/chat.js+16 −0web/static/style.css+27 −0
diff --git a/web/pages/index.html b/web/pages/index.html| index 2962bea..5104572 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -27,8 +27,10 @@ |
| </div> |
| </div> |
| </header> |
| - <div class="banner hidden" id="banner"></div> |
| - <div id="feed"></div> |
| + <div class="feed-wrap"> |
| + <div class="banner hidden" id="banner"></div> |
| + <div id="feed"></div> |
| + </div> |
| <div class="drop-overlay hidden" id="drop-overlay">Drop a photo to send</div> |
| <div class="composer-preview hidden" id="composer-preview"></div> |
| <form class="composer" id="composer" autocomplete="off"> |
diff --git a/web/static/chat.js b/web/static/chat.js| index 96102d9..1bc3fee 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -724,11 +724,26 @@ async function start(session) { |
| // 2) Live updates over WebSocket, with reconnect + gap fill. |
| let ws; |
| + let pingTimer = null; |
| + const stopPing = () => { |
| + if (pingTimer) clearInterval(pingTimer); |
| + pingTimer = null; |
| + }; |
| + // App-level keepalive: idle proxies drop WebSockets after ~60s, which caused the |
| + // "Reconnecting…" flicker. A small ping every 25s keeps the socket alive. The |
| + // server ignores unknown message types. |
| + const startPing = () => { |
| + stopPing(); |
| + pingTimer = setInterval(() => { |
| + if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" })); |
| + }, 25000); |
| + }; |
| function connect() { |
| const proto = location.protocol === "https:" ? "wss" : "ws"; |
| ws = new WebSocket(`${proto}://${location.host}/ws?token=${encodeURIComponent(session.token)}`); |
| ws.onopen = async () => { |
| showBanner("", false); |
| + startPing(); |
| // Fill anything missed while disconnected. |
| const missed = await fetchMessages(session.token, lastId); |
| for (const m of missed) await render(m); |
| @@ -745,6 +760,7 @@ async function start(session) { |
| } catch {} |
| }; |
| ws.onclose = () => { |
| + stopPing(); |
| showBanner("Reconnecting…", false); |
| setTimeout(connect, 1500); |
| }; |
diff --git a/web/static/style.css b/web/static/style.css| index 2b52302..aebf1ae 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -144,8 +144,16 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .menu-item.disabled, |
| .menu-item.disabled:hover { color: var(--muted); cursor: default; background: transparent; } |
| +.feed-wrap { |
| + position: relative; |
| + flex: 1; |
| + display: flex; |
| + flex-direction: column; |
| + min-height: 0; |
| +} |
| #feed { |
| flex: 1; |
| + min-height: 0; |
| overflow-y: auto; |
| padding: 18px; |
| display: flex; |
| @@ -454,6 +462,25 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| } |
| .banner.warn { color: #b4530a; } |
| +/* connection status as a floating pill so it never reflows the message list */ |
| +#banner { |
| + position: absolute; |
| + top: 8px; |
| + left: 50%; |
| + transform: translateX(-50%); |
| + z-index: 6; |
| + padding: 5px 14px; |
| + border-radius: 999px; |
| + background: var(--ink); |
| + color: #fff; |
| + font-size: 12px; |
| + font-weight: 600; |
| + box-shadow: 0 6px 20px rgba(20, 30, 80, 0.18); |
| + pointer-events: none; |
| + white-space: nowrap; |
| +} |
| +#banner.warn { background: #b4530a; color: #fff; } |
| + |
| /* ---- admin ---- */ |
| .admin { max-width: 760px; margin: 0 auto; padding: 24px; } |
| .admin h1 { margin: 0 0 4px; } |