feat(web): make a dead connection unmistakable instead of a red dot

Opening Codeman with nothing reachable (phone off the tailnet, VPN down,
server stopped) rendered a normal-looking UI: the service worker serves the
cached app shell, every /api call fails, and the only tell was an 8px red dot
in the header corner. On a phone that reads as "there are no sessions".

Two surfaces, chosen by whether there is anything worth looking at:

- Full-screen overlay while no server state has loaded this page load. It
  names the host, lists the three things to check (network, VPN/Tailscale,
  server), counts down to the next retry, and offers "Retry now" plus
  "Show cached view" to demote itself to the banner.
- Non-blocking banner once state HAS loaded, so a mid-session drop leaves the
  terminal scrollback readable.

A 2.5s grace keeps a COM deploy (SSE is back in ~200ms) from flashing the
banner every release; navigator.onLine === false skips the grace, since the
device saying "no network" is never a blip. Retry re-arms the terminal
WebSocket as well as SSE: planWsReconnect can give up outright, and the SSE
backoff caps at 30s, so waiting it out is not always an option.

The decision is pure (computeConnectionLossUi in constants.js, unit-tested in
a node VM like the WS reconnect policy); app.js only writes the DOM.
This commit is contained in:
Codeman maintainer
2026-08-09 15:56:44 +02:00
parent b1614e89fc
commit 696339fe12
7 changed files with 675 additions and 1 deletions
+44
View File
@@ -2872,3 +2872,47 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
padding: 4px 7px;
}
}
/* ============================================================================
Connection loss: phone sizing
The banner sits in normal flow directly under the fixed header (the container
already reserves that space), so it needs the same safe-area padding as the
other banners. The overlay is fixed and handles its own insets.
============================================================================ */
@media (max-width: 430px) {
.offline-banner {
padding: 0.4rem 0.5rem;
padding-left: calc(0.5rem + var(--safe-area-left));
padding-right: calc(0.5rem + var(--safe-area-right));
font-size: 0.7rem;
gap: 0.4rem;
}
/* The countdown is the first thing to go when the bar gets tight. The
wording plus the Retry button carry the message on their own. */
.offline-banner-detail {
display: none;
}
.offline-banner-retry {
padding: 0.25rem 0.5rem;
margin-left: auto;
}
.offline-overlay-card {
padding: 22px 18px 18px;
}
.offline-overlay-title {
font-size: 1.05rem;
}
.offline-overlay-actions {
flex-direction: column;
}
.offline-overlay-btn {
width: 100%;
padding: 0.65rem 1rem;
}
}